Ask three people what Cache-Control a Next.js page sends and you'll get three answers. All three can be right. It depends on how the page is rendered, which router you use, and whether Vercel sits in front of it.
I ran a live check on nextjs.org today to have something concrete to look at. Here's how to read what came back, and what changes when you host Next.js yourself.
The baseline: two requests to nextjs.org
The homepage HTML (trimmed to the caching-related headers):
cache-control: public, max-age=0, must-revalidate
x-vercel-cache: HIT
age: 572
x-nextjs-prerender: 1
x-nextjs-stale-time: 300
vary: rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch
A JavaScript chunk from the same page:
cache-control: public,max-age=31536000,immutable
x-vercel-cache: HIT
age: 48724
etag: W/"_next/static/immutable/chunks/3-f__u1tgqe9w.js"
These two responses cover most of what you need to know.
1. /_next/static is solved for you
Everything under /_next/static has a content hash in its filename, and Next.js serves it with a one-year immutable cache. You don't need to configure anything, and you shouldn't override it. If you put your own Cache-Control on that path through a reverse proxy, you can only make things worse.
The catch is everything that isn't under /_next/static. Files in /public (favicons, fonts you dropped in there, OG images, robots.txt) are served without a hash in the name, so Next.js can't give them a long cache. Self-hosted, they get public, max-age=0 by default, which means every visit revalidates or re-downloads them. If you own those files and they rarely change, a day of cache plus stale-while-revalidate is a reasonable middle ground. If they must change instantly, rename them.
2. Pages: the rendering mode decides the header
For HTML, Next.js derives Cache-Control from how the route is rendered. Roughly:
-
Static (prerendered at build): cached by shared caches until the next deploy. Self-hosted with
next start, you'll sees-maxagewith a very long value. -
ISR (
revalidate: 60orexport const revalidate = 60):s-maxage=60, stale-while-revalidate=.... The SWR window comes fromexpireTimeinnext.config(one year by default in recent versions). -
Dynamic (cookies, headers,
no-storefetches, search params read at request time):private, no-cache, no-store, max-age=0, must-revalidate.
Notice that the static and ISR headers use s-maxage, not max-age. They're instructions for a CDN, not for browsers. That's deliberate: the edge holds the page and refreshes it in the background, while browsers always come back to the edge and pick up new content after the revalidation window.
The dynamic case is the one to watch. Reading cookies() in a layout, even only to show a "Sign in" link, makes every page under that layout dynamic. Every page then gets no-store, and your CDN can't cache any of them. If your build output shows routes you expected to be static marked as dynamic (ƒ), this is usually why. Move the cookie read into a small client component or a route that actually needs it.
3. What Vercel changes
Look again at the nextjs.org HTML: public, max-age=0, must-revalidate, and still x-vercel-cache: HIT with an age of almost ten minutes.
That's Vercel's edge doing its own thing. It reads the s-maxage and stale-while-revalidate your app sends, uses them for its own cache, and then removes them from the response that goes to the browser, replacing them with max-age=0, must-revalidate. The browser is told to check back every time. Vercel does this so a purge or deploy takes effect immediately for everyone.
Two consequences:
-
On Vercel, the header you see in DevTools is not the header your app sent. Debugging ISR by reading the browser-facing
Cache-Controlwill mislead you.x-vercel-cache(HIT,MISS,STALE,PRERENDER,REVALIDATED) andageare what tell you how the edge is behaving. -
Self-hosted, nobody strips anything. If you put Next.js behind Cloudflare, Fastly or nginx, your CDN sees the raw
s-maxage=60, stale-while-revalidate=31535940. Whether it honours SWR depends on the CDN, and Cloudflare by default won't cache HTML at all without a Cache Rule. So "ISR works on Vercel" does not carry over automatically.
One more detail from the nextjs.org check: there's no ETag on the HTML. With max-age=0 and no validator, every browser navigation is a full download from the edge. It's fast because the edge is close, but it's not free. Self-hosted Next.js sends an ETag for pages by default (generateEtags: true), so this is mainly something you notice on Vercel.
4. headers() in next.config won't override page caching
A common attempt:
// next.config.mjs
export default {
async headers() {
return [{ source: "/:path*", headers: [{ key: "Cache-Control", value: "public, max-age=3600" }] }];
},
};
This works for /public files and API routes. For pages and /_next/static, the Next.js docs are explicit: in production, the framework sets Cache-Control for those itself, and your value gets overwritten. If you want a different cache lifetime for a page, change its revalidate. Don't fight the header.
Route handlers (app/api/.../route.ts) and the Pages Router getServerSideProps are the exceptions. There you set the header on the response yourself, and Next.js leaves it alone:
export async function GET() {
return Response.json(data, {
headers: { "Cache-Control": "public, s-maxage=300, stale-while-revalidate=60" },
});
}
5. The Vary header on App Router pages
vary: rsc, next-router-state-tree, next-router-prefetch, ... is there because the same URL can return two different bodies: full HTML on a normal navigation, or the React Server Components payload when the client router fetches it (with an RSC: 1 request header). A cache that ignores Vary can hand the RSC payload to a browser expecting HTML, and the user sees a page of serialized text.
Vercel handles this. If you self-host behind a CDN, check that it either respects Vary on these request headers or that you exclude RSC requests from caching. Recent Next.js versions also append an _rsc query parameter to those requests, which helps CDNs that key on the URL only.
A checklist for your Next.js app
- Run
next buildand look at the route table. Is anything dynamic that shouldn't be? - Check one static page, one ISR page and one asset with
curl -sI. Self-hosted, do you sees-maxage? On Vercel, look atx-vercel-cacheandageinstead. - Check something in
/public. Ismax-age=0what you want? - Behind your own CDN: is HTML actually cached (look for a HIT on the second request), and are RSC requests kept separate?
- Make sure nothing that reads cookies is shared through a
publicresponse.
I keep doing step 2 for client projects, so it's built into nocache.dev. There's a small package for Next.js too: withNocache() adds sensible caching for /public files and opt-in CDN caching for HTML paths you list, and checkUrls() runs a grade check after a deploy:
import { checkUrls } from "nocache-dev-next";
const { ok } = await checkUrls(["https://example.com/"], { minGrade: "B" });
process.exit(ok ? 0 : 1);
Or, without installing anything: npx nocache-dev check https://your-site.com. If a rule looks wrong for Next.js specifically, tell me. The methodology lists every check.
Top comments (0)