Next 15 SSG cache on a self-hosted nginx — what we learned the hard way
We spent a day staring at stale prod HTML before noticing that Next.js 15 emits `s-maxage=31536000` by default on every prerendered page, that `export const revalidate = N` doesn't change the emitted Cache-Control header (only the ISR background timer), and that our self-hosted nginx had no idea any of this had happened. Here's what fixed it.
If you run Next.js 15 on Vercel, this post probably doesn't apply to you — Vercel's edge cache integrates with Next's build pipeline and invalidates SSG routes at deploy time. If you self-host Next behind nginx (or any cache that doesn't get a deploy-time purge signal), you might inherit a foot-gun the framework documents but doesn't loudly warn about.
What Next 15 emits by default
For a prerendered (SSG) route, Next.js 15 ships the following on the response headers:
cache-control: s-maxage=31536000
x-nextjs-cache: HIT
x-nextjs-prerender: 1
x-nextjs-stale-time: 4294967294`s-maxage=31536000` is one year. The thinking is that on a Vercel-style edge cache, deploys send an explicit purge so the year doesn't matter — the cache is invalidated the moment the build lands. On a self-hosted nginx, no one sends that purge: the cache file stays valid for a year regardless of whether you've redeployed twelve times.
Why `revalidate = N` doesn't fix it
Our first attempt set `export const revalidate = 3600` on the locale layout. That's the right knob for ISR (background regeneration timer) but it controls `x-nextjs-stale-time`, not the emitted Cache-Control. The upstream nginx never reads `x-nextjs-stale-time` — it reads `cache-control`. So the deploy with revalidate landed and changed nothing visible at the edge.
What actually fixed it
Override the header explicitly in `next.config.ts`:
// next.config.ts
async headers() {
return [
{
source: "/:path*",
headers: [
{ key: "Cache-Control", value: "public, s-maxage=3600, must-revalidate" },
// ... other security headers
],
},
]
}This wins at the upstream layer regardless of what Next computes internally. Nginx re-fetches within an hour of any deploy. `must-revalidate` so SWR doesn't quietly serve a stale body past the TTL. Belt-and-suspenders alongside the layout `revalidate` keeps the ISR semantics if we ever move to Vercel.
Other things that wasted a day
- There was no CI/CD. `git push main` ran the changelog workflow but didn't trigger any image build. We had to add manual `docker build → save → scp lisahost → load → restart` to the runbook before any of the cache work could verify.
- The container was 6 days old. Without a curl that compared ETags, we'd have kept shipping fixes that never reached prod.
- `x-nextjs-prerender: 1` looks like a healthy signal, but it's just Next reporting it served from its own static-route cache. It says nothing about whether the upstream nginx layer matched.
Five PRs across two days finally got the marketing site's BP score from 96 to 100, its /dosia Performance from 61 to 92, and its SEO from 92 to 100 — all sitting in code yesterday, all invisible at the edge until today. None of those numbers improved when we wrote the fix; they improved when the build with the fix actually landed in a running container behind a cache layer that respected it.
“If you're writing fixes that show up in headers your edge layer doesn't get told about, you're going to spend a day staring at ETags before figuring out which side is lying.”