Writing · June 7, 2026 · 12 min read
The week my free tier ran out: moving Next.js rendering costs from runtime to build time
Next.js · SSG · caching · Supabase · cost optimization
TL;DR: My side project, a job board running on Next.js (Vercel Hobby) and Supabase (free tier), hit two free-tier limits in the same week: ~97% of the serverless Active CPU budget and 115% of the database egress budget. Instead of guessing, I traced it with observability tooling and found one dominant cause: SEO landing pages rendering dynamically on every request while pulling an entire table, heavy JSON column included, and filtering it in JavaScript. The fix was not "optimize the code" so much as move the work from an expensive budget (per-request CPU) to a cheap one (build minutes): static generation with client-side infinite scroll and event-driven cache invalidation. Along the way I had to untangle three different cache layers that are commonly confused, and learn to tell a broken app from a broken test environment.
Free has a ceiling
The project is pre-revenue, so the whole stack runs on free tiers: Vercel Hobby for the Next.js frontend, Supabase free for Postgres and auth. One week, two usage meters went red at the same time:
- Vercel Hobby, Fluid Active CPU: about 3 h 54 min used of a 4 h budget (~97%).
- Supabase free, egress: 5.76 GB of a 5 GB budget (115%, so already over).
Two things about the billing model turned out to matter a lot more than any framework detail:
- The window is rolling. Vercel Hobby counts the last 30 days from today, not a calendar month. There is no reset date to wait for. The only way out is to genuinely reduce usage.
- Active CPU is not wall-clock time. You pay for the time a function actively computes, not the time it waits on I/O. A function that waits five seconds for the database is cheap. A function that spends half a second crunching data in a loop is expensive. That inverts a lot of intuition.
The strategic takeaway that shaped everything after: cost lives in budgets, and budgets are uneven. Active CPU was the scarce resource. Build minutes were, at my scale, effectively unlimited. So the entire project boiled down to one sentence: move work from the expensive resource (render per request) to the cheap one (prerender at build time).
Finding the culprit instead of guessing
I did not want to optimize on vibes. Three tools did the actual detective work:
- Vercel Observability (Functions view) showed which routes burned Active CPU. The job detail page dominated, with the SEO landing pages close behind.
- Supabase API Gateway logs showed which REST calls generated the traffic.
pg_stat_statementsshowed which queries cost the most inside Postgres itself.
The key reading: a single query pattern accounted for 88% of all API traffic. Every request to an SEO landing page (category pages, country pages, and a country × category matrix) was:
- fetching the entire active-jobs table (a few hundred rows),
- including a heavy AI-generated JSON column plus embeddings and search vectors, roughly 13 KB per row, of which the page used a fraction,
- then filtering by country and category in JavaScript, inside the serverless function, because at some point that had been "simpler".
The pain math: roughly 1,900 invocations per day × ~100 KB gzipped per response ≈ 190 MB per day ≈ 5.5 GB per month. That is essentially the entire egress budget spent on one query. And each of those invocations was also a serverless function scanning a few hundred heavy rows in JS, so the same mistake was draining both meters at once.
The comment that lied
My favorite find of the whole investigation. Next to the in-JS filtering sat a comment claiming the database layer "doesn't support filtering on JSON paths". It was false. A few files away, in the same repo, the filter-count badges were already filtering and sorting on exactly that same JSON path, database-side. Someone (me, months earlier) had written a wrong mental model down as a fact, and that comment then justified the anti-pattern in three more files. Nobody questions a confident comment.
The most expensive bug is not a typo. It is a false assumption written down as fact, which then stops being questioned.
The data-layer fix was straightforward once the assumption fell: select only the columns the list card actually renders (~3 KB per row instead of ~13 KB) and push the country/category filter into the database. That alone cut ~95% of egress on that path. But it treated a symptom. The real question was: why render these pages on every request at all?
The rendering spectrum, and who pays for it
Next.js App Router gives you several rendering strategies. They differ in when HTML gets produced and who pays the CPU for it:
| Strategy | HTML is produced | Who pays CPU | Build symbol |
|---|---|---|---|
| CSR | in the browser, after JS loads | the user's device | — |
| SSR / dynamic | on every request, on the server | my server, per request | ƒ |
| SSG | once, at build time | my build (cheap), then zero | ● |
| ISR | like SSG, refreshed on TTL or on demand | build + occasional re-render | ● with revalidate |
My landing pages were ƒ Dynamic. Every crawler hit, every visitor, meant one server render, which meant Active CPU. The goal was to make them ● SSG: rendered once at build time, then served as finished HTML from the CDN for free.
What forces a page dynamic
A page becomes dynamic automatically the moment it reads something that only exists at request time: cookies(), headers(), or, my killer, searchParams. The landings had URL pagination (?page=N). As long as a page reads searchParams, it cannot be static, because ?page=3 is different content from ?page=1 and the build cannot know which one to bake. To go static, URL pagination had to go.
The SSG toolkit I ended up using:
export const dynamic = "force-static", which also acts as a tripwire: if anything accidentally pushes the page dynamic again, the build fails instead of silently regressing.generateStaticParams()to enumerate which category and country pages to prerender.dynamicParams = trueso long-tail params outside the list render on demand at first visit instead of 404ing.- A very long
revalidateas a distant safety net, not the primary freshness mechanism (more on that below).
Three cache layers everyone confuses
Before any of this worked reliably, I had to accept that "cache" in a Next.js + Vercel + CDN stack is three different things, and invalidating one does not touch the others:
- Data Cache: the result of
fetch/unstable_cachecalls, keyed by arguments plus tags. Busted withrevalidateTag(). - Full Route Cache: the finished, prerendered HTML/RSC of the whole page. Busted with
revalidatePath(). - CDN edge cache: the copy of that HTML at the edge. Busted with a purge call to the CDN.
The least obvious consequence: on a force-static page, revalidateTag() alone does not guarantee fresh HTML. It busts the data, but the baked page can stay stale. And if you only purge the CDN, the edge just re-fetches the same stale HTML from the origin. A real refresh needs revalidatePath() plus a CDN purge.
Another mental model that once burned me, now written down:
unstable_cache caches a snapshot of a query result, not "those rows". The key is the query arguments, not the state of the table. Inserting a new row does not cause a cache miss; the same key stays a HIT with the old result until the TTL passes or you revalidate explicitly. "The row is new, so the next render will show it" is exactly wrong: what matters is when the cache entry was made, not when the row was born.Proof of concept: the detail page
I started with the simplest, highest-CPU route: the job detail page. Its content is immutable after creation, which makes it a perfect SSG candidate. The build prerendered over a thousand detail pages in about 40 seconds, and the dominant CPU consumer disappeared from runtime entirely, landing in the build-minutes budget instead.
The per-user part of the page (a personalized matching panel) became a client-side island that fetches after load, so that reading the user's cookies would not drag the whole page back into dynamic rendering. That became the pattern I repeated everywhere: a static shell plus client islands of dynamism. Everything shared by all visitors is baked once; only what is per-user or per-now is computed in the browser.
The main course: static landings with infinite scroll
Dropping ?page= pagination raised a UX question: how do you show a few hundred offers without pagination? The answer was client-side infinite scroll over prerendered data. The page ships with the full list serialized in its payload, but mounts only the first 20 cards in the DOM. An IntersectionObserver watches a sentinel element at the end of the list and reveals the next batch as you scroll. No extra fetches, no per-frame JavaScript (intersection math happens off the main thread), and a plain "show more" button as the no-JS and keyboard fallback.
I also capped the prerendered list and added a "see everything with full filters" banner past the cap, to bound both DOM size and HTML weight before the largest category ever gets there.
Returning to the exact same spot
The detail I cared about most as a user: click an offer halfway down a long list, read it, go back, and land exactly where you were, same revealed cards, same scroll position. This does not work by itself: on back-navigation the list remounts with 20 cards, and the browser's native scroll restoration tries to scroll to a position that does not exist yet. So the order matters: first restore the revealed-card count, then (a couple of animation frames later) restore the scroll position. I stash a tiny record at click-time and read it SSR-safely on return via useSyncExternalStore.
Why sessionStorage and not localStorage, cookies, or IndexedDB? It is per-tab and vanishes when the tab closes (no stale restores in tomorrow's session), it never travels to the server (unlike a cookie), and it reads synchronously at mount, so there is no flicker before the scroll restores. IndexedDB would be a cannon aimed at a fly for one small object.
The freshness paradox
"If everything is static, how does anything ever update?" The answer has two layers.
Freshness is event-driven, not time-driven. When the backend adds or removes an offer, it calls a revalidation hook on the frontend, which runs revalidatePath() for the affected landings and purges the CDN. Pages refresh when something actually changed, not "every N minutes just in case", which would burn the very CPU I was saving. The time-based revalidate stays as a distant safety net.
The subtlety that bit me: rewrites. Some public URLs are rewritten in config to a different physical route shape. The CDN purge must receive the public URL, because the edge caches what is in the address bar. revalidatePath() must receive the resolved route, because Next keys the Full Route Cache by the physical path. Mix those up and you get the maddening "purge succeeded but the page is still stale". Two layers, two URL shapes.
One product decision fell out of SSG too: I removed the live counts from footer links ("Germany (42)" style). On a static page such a number freezes at build time and starts lying. Better to show no number than a wrong one.
Is my code broken, or is my environment?
The build confirmed the migration (dynamic ƒ became static ●), and then the Playwright e2e test for infinite scroll started failing in ways that looked exactly like a hydration bug. It was not. Each hypothesis fell to a separate, cheap experiment:
- "Hydration is broken." Clicking the "show more" button revealed cards. React was alive. Falsified.
- "IntersectionObserver doesn't fire." A single-step jump-scroll to the bottom generates no render frames in a headless browser, so the observer never sees the intersection. Incremental scrolling (steps of ~500 px) works, and is also a truer simulation of a real user. That was a bug in the test, not the code.
- "Details 404 after click." The local production server does not serve prerendered payloads for on-demand static params during SPA navigation the way the real platform does; under an aggressive test loop this hammered the database API into rate limiting, which surfaced as 404s. An artifact of the local environment, not an application bug: in production those navigations are served from the CDN.
Half of debugging is not fixing code. It is establishing whether you are even looking at a code bug, as opposed to a test bug, an environment artifact, or a wrong assumption. It goes fastest when each hypothesis gets its own cheap, falsifying experiment.
Results and lessons
The rendering cost of the three main SEO surfaces plus the detail pages moved from the scarce budget (per-request Active CPU) to the abundant one (build minutes). Egress dropped by an order of magnitude thanks to lean column selection and database-side filtering. Pages ship as static HTML from the CDN; dynamism lives in client islands; freshness is event-driven.
What I would repeat on any project:
- Measure first. Observability, gateway logs and
pg_stat_statementspointed at one query pattern. Guessing would have had me optimizing the wrong thing. - Cost lives in budgets. Find the scarce budget and move work to an abundant one, rather than shaving percents off in place.
- Name the cache layers. Data Cache, Full Route Cache, CDN edge. Invalidation bugs are almost always "busted one, forgot the other two".
- Static shell, client islands. The default shape for shared content with per-user extras.
- Comments can lie. A false assumption written down as fact costs more than no comment at all.
There is a sequel to this story: after the migration, Active CPU went up before it went down, for a reason that had nothing to do with rendering strategy. That one is covered in Static doesn't mean free.