DEV Community

Andrea Roversi
Andrea Roversi

Posted on • Originally published at roversia.it

Summarizing a PDF with AI for less than a cent: PDF.js, Gemini Flash-Lite, and a Netlify Function

I wanted to add a tool to my site's PDF cluster that summarizes uploaded documents and contracts, without blowing up costs or breaking the zero-heavy-server-processing principle I follow for every other tool. Here's the architecture I picked, the real economics behind an AI-generated summary, and an authentication snag I didn't expect: Google's newer API keys use a different format than the one I'd always worked with, and they silently break the most common authentication method used in tutorials and existing code.

The problem: what does it actually cost to have an AI read a PDF?

The starting idea was simple: a tool where the user uploads a PDF — a contract, a report, a long document — and gets back a structured summary in a few seconds. The rest of the site's PDF cluster (merge, split, compress, OCR) already processes everything client-side, at zero cost. An AI summary is different by nature: it necessarily needs a call to a language model, and that has a real per-token cost. Before writing a single line of code, the real question was: what does this cost me at scale, if the tool actually gets used?

With a cheap model like Gemini Flash-Lite, the numbers were more reassuring than I expected. For a twenty-page-ish contract (roughly 13,000 input tokens plus the instruction prompt, and a 500-token summary in output), a single call costs on the order of a few thousandths of a dollar — most of the cost comes from input volume, priced far lower than output tokens. Even much longer documents stay under one or two cents. On a monthly basis, even with sustained traffic for a personal site, the worst-case spend stays in the tens of euros.

The real cost risk isn't normal use, it's abuse: someone looping the call or repeatedly uploading huge documents. That's why I built in a rate limit from day one, not as a later optimization.

Architecture: browser-side extraction, server-side AI processing

The decision that shaped everything else was to keep text extraction client-side, reusing the same PDF parsing library already used elsewhere in the site's PDF tools, and to send the serverless function only clean text, never the binary file.

Layer Choice
Text extraction PDF.js, entirely in the browser
Backend Netlify Function, receives text only
AI model Gemini Flash-Lite via REST API
Rate limiting Per-IP/day counter on Firestore, via Admin SDK
Document storage None — processed on the fly, nothing is saved

The main reason isn't only cost: serverless functions have tight limits on payload size and execution time. A heavy scanned PDF could easily blow past them. By extracting text in the browser, the function receives a small, predictable payload, the token cost is lower since there's no binary markup to process, and timeout risk drops close to zero. For scanned PDFs (image, not real text), OCR remains available as an optional preliminary step.

Rate limiting without forcing a login

I didn't want to put a login wall in front of a tool meant for quick, occasional use. The solution is a per-IP daily counter, saved server-side with the same service credentials already used by the site's other functions, checked before every model call and incremented right after. Once a daily threshold is hit, the function returns an explicit error instead of calling the AI.

One detail that made me stop and think: the database security rules stay at allow read, write: if false for all direct client traffic, with no need to add an exception for the new counter collection. Serverless functions use the Admin SDK with service credentials, which always bypasses the rules — so the global "deny all" automatically covers a collection created later too, with no changes needed.

The unexpected snag: an API key in a format I'd never seen

When it came time to actually wire up the API key, I got one starting with a different prefix than what I'd always worked with in previous projects. My first instinct was to assume a mistake — maybe an OAuth token copied by accident instead of a real API key.

It wasn't a mistake: it's a newer "Auth Key" format that the latest key-creation interfaces generate by default. The real problem, though, was technical: keys in the new format don't work when passed as a ?key= URL parameter — the most common method in tutorials and in already-written code — and return an authentication error. They expect a dedicated HTTP header instead.

// Before: breaks with the newer key format
const resp = await fetch(
  `https://generativelanguage.googleapis.com/v1beta/models/${MODEL}:generateContent?key=${apiKey}`,
  { method: 'POST', headers: { 'Content-Type': 'application/json' }, body }
);

// After: works with both the new and the previous key format
const resp = await fetch(
  `https://generativelanguage.googleapis.com/v1beta/models/${MODEL}:generateContent`,
  {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', 'x-goog-api-key': apiKey },
    body
  }
);
Enter fullscreen mode Exit fullscreen mode

If a project suddenly stops authenticating with Google after regenerating a key, this is the first thing worth checking.

A side note on operational security: during debugging, the key was accidentally pasted in plain text into a chat. Even in a private channel, a key seen by anyone else (or by another system) should be treated as potentially compromised: the correct move is to rotate it immediately, not to keep using it because "no one really saw it."

Verifying the key before deploy, not after

Instead of wiring the regenerated key straight into the production function, I prepared an isolated Node.js script that makes a single test call to the model and prints only the outcome and the model name — never the key itself. On Windows, the only snag was remembering that the syntax for setting an environment variable in PowerShell differs from bash: two separate commands instead of one line.

Once I confirmed the key worked and that the model name was actually available for the account, wiring it into Netlify as a function-scoped environment variable was the last, uneventful step.

From a "form-only" page to an indexable tool

The first version of the tool page was, in practice, almost just a file upload form: functional for someone who already knows what to do, but a problem for organic search. A page with little indexable text around the form gets classified as "thin content" — search engines don't have enough signal to understand what it's about or which queries to show it for.

I added: hreflang tags, structured data for BreadcrumbList and FAQPage (on top of the existing WebApplication schema), a three-step "How it works" section, a visible FAQ matching the structured data content, and internal links to related tools. All validated before shipping: balanced markup, all JSON-LD blocks parsing correctly, no credentials in the code.

One almost comic detail: after wiring up the tool, I noticed it was missing the animated canvas background present on every other page of the site. The cause was trivial — I'd used a plain <div id="canvas-container"> in the page instead of the <canvas id="canvas"> element the shared animation script explicitly looks up by id. A reminder of how a small detail, copied wrong from a template, can go unnoticed until someone spots it by eye.

What I take away from this project

The most useful lesson isn't strictly technical: a "free for the user" tool is never truly zero-cost for whoever maintains it, and it's worth doing the math before writing code, not after. In my case the numbers confirmed the project was sustainable — but rate limiting is still the first piece I wrote, not the last. The same goes for operational security: a key seen by an extra pair of eyes gets rotated, full stop, without calculating how "likely" it is that it was actually used.


Originally published on roversia.it, where I write about the vanilla JS/Firebase/Netlify stack behind my personal projects.

Top comments (0)