The page that never hit the cache: a Next.js lab you can run
A marketing page that should have been cached was rendered on the server for every visitor. I rebuilt the failure in a lab with pinned versions, measured it, and applied the three fixes one at a time. Two of the three, on their own, change nothing.
A page that should be served from the cache, and isn't, looks fine. It loads. It renders. Nobody files a bug. The only sign is that every visitor waits for the CMS, every time, and the hosting bill scales with traffic instead of staying flat. I've watched this happen on a production site and I can't write about that one. So I rebuilt the failure in a lab you can run in ten minutes, with the version numbers pinned, and I want to show you the three fixes and why any one of them alone does nothing.
The setup
Next.js 15.5.4, App Router, one page that fetches its content from a headless CMS. The CMS is a 20-line Node server with 400 ms of artificial latency and a timestamp in the body, so a cache hit is visible to the eye: if the timestamp changes between two requests, the CMS was called again. A page also reads a consent cookie, which is the kind of harmless-looking line that causes the whole problem.
// cms.js: a stand-in for the headless CMS
const http = require('http'); let calls = 0;
http.createServer((req, res) => { calls++; setTimeout(() => {
res.setHeader('content-type', 'application/json');
res.end(JSON.stringify({ title: 'Store locator',
generatedAt: new Date().toISOString(), cmsCalls: calls }));
}, 400); }).listen(4000);
Before: the page everyone ships first
// app/before/page.js
import { cookies } from 'next/headers';
export default async function Page() {
const jar = await cookies();
const consent = jar.get('consent')?.value ?? 'none';
const res = await fetch('http://localhost:4000/page', { cache: 'no-store' });
const data = await res.json();
return <main><h1>{data.title}</h1>
<p>CMS generated at {data.generatedAt}</p><p>consent: {consent}</p></main>;
}
Two things are wrong and each is enough on its own. The cookies() call tells Next.js the page depends on the request, so the route becomes dynamic and is rendered on the server for every visitor. The cache: 'no-store' on the fetch tells Next.js never to keep the CMS response, so every render pays the 400 ms again. The build output says so, if you know where to look: a ƒ next to the route means dynamic, a ○ means static.
Route (app) Revalidate Expire
├ ƒ /before
├ ƒ /half-a
├ ƒ /half-b
├ ○ /after 5m 1y
The two half-fixes that look like fixes
This is the part that costs teams weeks. Someone removes the cookie read. Someone else, in a different ticket, adds a revalidate window to the fetch. Both changes get merged, both look right in review, and the page is still rendered on every request. I built both halves as separate routes so you can see each one fail.
Half A removes cookies() but keeps cache: 'no-store'. The route is still dynamic, and the timestamp still changes on every request. Nothing improved.
Half B keeps cookies() but caches the fetch with next: { revalidate: 300 }. The timestamp stops changing, because the data cache now holds the CMS response, and the server render drops from about 415 ms to about 12 ms. That feels like a win. But the route is still dynamic, and the response still carries Cache-Control: private, no-cache, no-store, which means no CDN or edge cache in front of it will ever store the page. Every visitor still reaches your server. You've moved the cost, not removed it.
After: all three, together
// app/after/page.js
export default async function Page() {
const res = await fetch('http://localhost:4000/page', { next: { revalidate: 300 } });
const data = await res.json();
return <main><h1>{data.title}</h1>
<p>CMS generated at {data.generatedAt}</p><ConsentBadge /></main>;
}
// The cookie-dependent part moves to the client and reads
// document.cookie after hydration. The server never sees it.
function ConsentBadge() {
return <p id="consent" suppressHydrationWarning>consent: (read on the client)</p>;
}
Fix one: no request-time APIs (cookies(), headers(), searchParams) anywhere in the page tree. Fix two: the CMS fetch gets a revalidate window instead of no-store. Fix three: whatever needed the cookie moves to a client component, so the personalised bit happens in the browser and the HTML stays the same for everyone. Now the route is static with a five-minute revalidate, the response says s-maxage=300, stale-while-revalidate, and a CDN will serve it without touching your server.
What the numbers looked like
Five requests per route against the production build on one machine, measured with curl. The CMS counter is the number of times the mock CMS was called across the run.
| Route | Build | Response time | Cache-Control | CMS called per request |
|---|---|---|---|---|
| /before | ƒ dynamic | ~415 ms | private, no-store | Yes |
| /half-a (cookie removed) | ƒ dynamic | ~415 ms | private, no-store | Yes |
| /half-b (fetch cached) | ƒ dynamic | ~12 ms | private, no-store | No, but the server renders every time |
| /after (all three) | ○ static, 5m | ~4 ms | s-maxage=300, stale-while-revalidate | No |
The response times are from a laptop-class container with no network in the way, so treat them as ratios rather than absolutes. The ratio is the point: the before page is a hundred times slower than the after page, and the half-fix that looks fastest still leaves the CDN out of the picture.
How to check your own site in two minutes
Run next build and read the route table. Any page that should be the same for everyone and shows ƒ is a candidate. Then request the live page with curl -D - -o /dev/null https://yoursite/page and read two headers. Cache-Control starting with private, no-cache, no-store means nothing in front of your server will keep it. On Vercel you'll also see x-vercel-cache, where DYNAMIC means every request reached a function and HIT means the edge served it. On a self-hosted build, x-nextjs-cache: HIT tells you the data cache or full route cache was used.
If a page you expected to be static shows dynamic, search the page and every layout above it for cookies(, headers(, searchParams, no-store and dynamic = 'force-dynamic'. One of them is there, often in a layout written months earlier, and often by a different team than the one writing the page.
Why a product manager should care
This isn't a performance nicety. The cost of a dynamic page is a function of traffic, and the cost of a cached page is close to zero past the first visitor. When a marketing page goes dynamic by accident, the hosting bill and the CMS bill both start tracking visits, and the page gets slower during exactly the campaigns that matter. The fix is three lines, but finding it needs someone who reads build output and response headers as product signals, not just engineering ones. That's the job I think a technical product manager is for.
Run it yourself
Six files, no accounts, no hosting. npm install next@15.5.4 react@19.1.1 react-dom@19.1.1, create the pages above under app/, start node cms.js on port 4000, then npx next build && npx next start. The CMS has to be running during the build because the static pages fetch at build time; that's the same reason a real CMS outage can break a deploy. Curl each route five times and watch the timestamp.
Sources and further reading
- Next.js docs: Caching. The four caches (request memoization, data cache, full route cache, router cache) and what opts a route out of each.
- Next.js docs: cookies(). The note that reading cookies makes a route dynamic.
- Vercel docs: Edge caching and the x-vercel-cache header. What HIT, MISS, STALE and DYNAMIC mean.