Next.js buildId and Static Chunk Invalidation
Next.js fingerprints your application twice. Individual JavaScript and CSS chunks carry a content hash in their filename, and on top of that every deploy gets a single opaque identifier — the buildId — that is baked into a directory segment at /_next/static/<buildId>/. Understanding which files sit behind which layer is the difference between a caching setup that survives a rollout and one that serves 404s to anyone who happened to have a tab open.
The symptom that brings people here is specific: a user who loaded the site twenty minutes ago clicks a link to a page they have not visited, and instead of a client-side transition they get a full page reload, a flash of white, or a console error about a failed data fetch. Nothing is wrong with the chunk hashes. The buildId moved.
Where the buildId Comes From
By default Next.js generates a random identifier per build and writes it to .next/BUILD_ID:
next build
cat .next/BUILD_ID
# Nl9K3xR2pQvT8mYbC1dZa
That value is then substituted into every URL Next.js emits under /_next/static/<buildId>/, and into the /_next/data/<buildId>/ route-payload prefix used by the Pages Router. It is also embedded in the __NEXT_DATA__ script tag in every server-rendered HTML document, which is how the client-side router knows which prefix to use for its own requests.
Critically, the buildId is not derived from your source. Two builds of the same commit produce two different default IDs. That breaks the property you actually want from a fingerprint — that identical inputs produce identical outputs — and it is why the first configuration change on any production Next.js deployment should be pinning it.
/_next/static/ and both are safe to serve as immutable — but only the lower one changes for the reason you expect.The distinction is easy to state and easy to get wrong. /_next/static/chunks/main-a1b2c3d4.js changes when the bytes of that chunk change and at no other time; two consecutive deploys with no source changes emit the identical filename. /_next/static/<buildId>/_buildManifest.js changes on every single deploy regardless of whether anything in it differs. The manifest is a small file — typically 2–8 KB — that maps every route to the list of chunks it needs, and the router fetches it before it can prefetch anything.
Path Behaviour Reference
| Path | Hashed by | Recommended TTL | Survives a redeploy |
|---|---|---|---|
/_next/static/chunks/*.js |
8-hex [contenthash] of chunk bytes |
max-age=31536000, immutable |
Yes — identical bytes keep the identical name |
/_next/static/css/*.css |
8-hex [contenthash] of extracted CSS |
max-age=31536000, immutable |
Yes |
/_next/static/media/* |
8-hex [contenthash] of the source file |
max-age=31536000, immutable |
Yes |
/_next/static/<buildId>/_buildManifest.js |
buildId only |
max-age=31536000, immutable |
No — new directory every deploy |
/_next/static/<buildId>/_ssgManifest.js |
buildId only |
max-age=31536000, immutable |
No |
/_next/data/<buildId>/<route>.json |
buildId + route |
s-maxage in the tens of seconds |
No |
/ and other HTML routes |
nothing — stable URL | no-cache, must-revalidate |
N/A — always re-fetched |
The counterintuitive row is the manifest. It is safe to mark immutable even though it changes every deploy, because the URL never changes meaning: a given <buildId> directory holds exactly one version of that file, forever. What changes is which URL the HTML points at. This is the same reasoning that makes content-hashed chunks cacheable, applied at a coarser granularity, and it is worth reading alongside deterministic build outputs for why reproducibility is the property that makes both layers trustworthy.
Pinning the buildId with generateBuildId
Pin the value to the commit SHA. It makes the deploy reproducible, makes the currently-live version diagnosable from a single curl, and — most importantly — makes every instance in a fleet agree.
// next.config.js
const { execSync } = require('child_process');
function resolveBuildId() {
const fromEnv =
process.env.GIT_COMMIT_SHA ||
process.env.GITHUB_SHA ||
process.env.CI_COMMIT_SHA;
if (fromEnv) {
return fromEnv.slice(0, 8);
}
return execSync('git rev-parse --short=8 HEAD').toString().trim();
}
/** @type {import('next').NextConfig} */
const nextConfig = {
generateBuildId: async () => resolveBuildId(),
assetPrefix: (process.env.NEXT_PUBLIC_CDN_URL || '').replace(/\/$/, ''),
async headers() {
return [
{
// Both the content-hashed files and the per-deploy manifests.
// Every URL under this prefix holds exactly one version forever.
source: '/_next/static/:path*',
headers: [
{ key: 'Cache-Control', value: 'public, max-age=31536000, immutable' }
]
},
{
// Route payloads are buildId-scoped but data-dependent.
// Cache briefly at the edge, never in the browser.
source: '/_next/data/:path*',
headers: [
{ key: 'Cache-Control', value: 'public, s-maxage=30, stale-while-revalidate=300' }
]
},
{
// HTML must always be revalidated; it is what names the buildId.
source: '/:path((?!_next).*)',
headers: [
{ key: 'Cache-Control', value: 'no-cache, must-revalidate' }
]
}
];
}
};
module.exports = nextConfig;
Eight hex characters is the right default. In a monorepo where several Next.js applications publish into one CDN path prefix, widen to 12–16 so that two apps built from adjacent commits cannot land on the same directory name.
The matching CDN rules — Cloudflare Cache Rules, expressed as Nginx here for self-hosted origins — must agree with the headers() block, or whichever layer is more aggressive wins:
# /etc/nginx/conf.d/nextjs-cache.conf
# Everything under _next/static: hashed chunks AND per-buildId manifests.
location ^~ /_next/static/ {
proxy_pass http://nextjs_upstream;
proxy_cache nextcache;
proxy_cache_valid 200 365d;
add_header Cache-Control "public, max-age=31536000, immutable" always;
}
# Route payloads: short edge TTL, no browser storage.
location ^~ /_next/data/ {
proxy_pass http://nextjs_upstream;
proxy_cache nextcache;
proxy_cache_valid 200 30s;
add_header Cache-Control "public, s-maxage=30, stale-while-revalidate=300" always;
}
# HTML: revalidate on every request so a rollout is visible immediately.
location / {
proxy_pass http://nextjs_upstream;
add_header Cache-Control "no-cache, must-revalidate" always;
}
On Cloudflare the same three rules become Cache Rules ordered by specificity, with Edge TTL 1 year for /_next/static/*, 30 seconds for /_next/data/*, and Bypass for everything else. AWS CloudFront expresses it as three cache behaviours with matching path patterns and a response headers policy carrying the Cache-Control value. The reasoning behind each TTL choice is expanded in cache-control immutable and TTL tuning.
Why a Random buildId Breaks a Rolling Deploy
A rolling deployment replaces instances one at a time. If each instance runs next build independently — which happens in any pipeline that builds inside the container image entrypoint, or that rebuilds per replica — each one generates a different random buildId and serves HTML advertising its own value. Requests are load balanced, so a client is not guaranteed to reach the instance whose HTML it received.
There is a second, quieter reason to pin. A random identifier makes the origin’s asset tree grow without bound: every redeploy of an unchanged commit adds another _next/static/<buildId>/ directory that nothing prunes. Pinning to the SHA makes redeploying the same commit idempotent — the directory is simply overwritten with identical content.
Build the application once, in one CI job, and ship the same artifact to every instance. That is the same discipline described in CI/CD asset pipeline integration, and pinning generateBuildId is what makes a violation of it visible instead of silent.
What Happens to a Client Holding an Old buildId
Consider a tab opened before the rollout. Its chunks are already downloaded and cached. It has the old _buildManifest.js in memory. Nothing breaks while the user stays on pages the tab has already rendered — those chunks are content-hashed and still resolve.
The failure starts on a navigation to a route the tab has never visited. The router needs that route’s payload, and it asks for it under the prefix it knows:
GET /_next/data/7f3a91c2/pricing.json
If 7f3a91c2 is no longer present at origin, the response is a 404. The router treats a failed data fetch as unrecoverable for client-side routing and falls back to a hard navigation — it sets window.location and lets the browser fetch the document fresh. The user sees a full page load instead of an instant transition, and the new document names the current buildId, so the tab is healthy from that point on.
This recovery only works if the document request itself succeeds. If the bad buildId directory is deleted and the HTML is still cached at the edge pointing at it, the reload fetches the same stale HTML, requests the same missing manifest, and the tab is stuck. That is why the HTML tier must always be revalidatable.
The way to avoid the visible reload entirely is retention: keep the previous build’s _next/static/<buildId>/ and _next/data/<buildId>/ trees at origin for the length of a plausible session. Fifteen minutes covers most consumer traffic; thirty to sixty is appropriate for dashboards and editors where tabs stay open for hours. Storage cost is trivial — a manifest pair is under 10 KB per build — and the retention window is what turns a rollout into a non-event. The operational procedure for exercising this during an incident is in rolling back Next.js static assets after a bad deploy.
Verification
Check both path shapes against a deployed environment. The two should return identical cache headers and differ only in whether the URL survives your next deploy:
#!/usr/bin/env bash
set -euo pipefail
DOMAIN="https://www.example.com"
# Pull the live buildId straight out of the served HTML.
BUILD_ID=$(curl -s "${DOMAIN}/" \
| grep -o '/_next/static/[^/]*/_buildManifest.js' \
| head -1 | cut -d/ -f4)
echo "live buildId: ${BUILD_ID}"
echo "--- deploy-scoped path ---"
curl -I -s "${DOMAIN}/_next/static/${BUILD_ID}/_buildManifest.js" \
| grep -iE '^(HTTP/|cache-control|age|cf-cache-status|x-cache):'
echo "--- content-scoped path ---"
CHUNK=$(curl -s "${DOMAIN}/" \
| grep -o '/_next/static/chunks/[a-zA-Z0-9._-]*\.js' \
| head -1)
curl -I -s "${DOMAIN}${CHUNK}" \
| grep -iE '^(HTTP/|cache-control|age|cf-cache-status|x-cache):'
Expected output for both requests:
HTTP/2 200
cache-control: public, max-age=31536000, immutable
cf-cache-status: HIT
age: 4102
Then redeploy the same commit and re-run it. The chunk URL must be byte-identical; the buildId in the manifest URL must also be identical, because you pinned it. If the manifest URL changed while the chunk URL did not, generateBuildId is not doing what you think — check that the environment variable your CI sets is actually present in the build container.
To confirm the previous build is still reachable during a rollout, keep the prior ID and probe it directly:
curl -o /dev/null -s -w "prev manifest: %{http_code}\n" \
"https://www.example.com/_next/static/7f3a91c2/_buildManifest.js"
# 200 while the retention window is open, 404 once it is pruned
When to Reconsider
When a stable buildId is the wrong choice. If you deploy the same commit to several environments that share one CDN hostname — a preview alias and production, say — a SHA-derived ID makes both write to the same directory. Either give each environment its own path prefix via assetPrefix, or fold the environment name into the ID: `${env}-${sha}`.
When you cannot reach git at build time. Container images built from an exported tarball, or CI systems that check out with --depth=1 into a non-repo directory, will fail git rev-parse. Read the SHA from the environment instead and fail the build loudly if neither source is available — a silent fallback to a literal string like 'local' in production is worse than a random ID, because every deploy then reuses one directory and clients cache a manifest that no longer matches the chunks.
When the App Router changes the picture. The App Router does not use /_next/data/; it fetches React Server Component payloads from the route URL itself with an RSC: 1 request header. The buildId still scopes _buildManifest.js and still appears in __NEXT_DATA__, and a mismatch still triggers a hard navigation — but the 404 you would grep for in access logs is on the page path, not on a .json file.
When per-deploy invalidation is all you need. Small applications that ship one bundle and rarely change can skip content hashing entirely and lean on the buildId directory alone, accepting that every deploy invalidates every asset. That is a real tradeoff, not a mistake, when the total payload is under about 100 KB and deploys are infrequent.
FAQ
Does changing generateBuildId change my chunk hashes?
No. Chunk hashes are computed from module content by Webpack and are independent of the buildId. You can switch from a random ID to a SHA-derived one and see every filename under /_next/static/chunks/ stay exactly the same. Only the /_next/static/<buildId>/ directory name and the /_next/data/<buildId>/ prefix move.
Can I mark _buildManifest.js immutable even though it changes every deploy?
Yes, and you should. The file at a given <buildId> URL is written once and never rewritten. Immutability is a property of the URL-to-bytes mapping, not of how often new URLs appear. What must not be immutable is the HTML that decides which <buildId> URL to request.
How long should I keep the previous buildId’s directory at origin?
At least as long as your longest plausible open tab. Fifteen minutes is a reasonable floor for consumer sites; thirty to sixty minutes suits applications where users leave a tab open across a working session. Keeping two or three prior builds costs a few tens of kilobytes and removes an entire class of rollout error.
Why did my deploy produce a different buildId even though nothing changed?
Either generateBuildId is unset — in which case Next.js generates a fresh random value every build — or the environment variable it reads is missing in the build container and it fell through to a different branch. Print the resolved value during the build and assert it against the commit SHA in CI rather than discovering the mismatch from a 404 graph.
Related
- Next.js Static Asset Handling — parent guide covering the full
next.config.jssurface,assetPrefix, and output modes - Rolling Back Next.js Static Assets After a Bad Deploy — artifact retention and selective purging when a build ID has to be withdrawn
- Deterministic Build Outputs — why identical inputs must produce identical filenames for either layer to be trustworthy
- Cache-Control Immutable and TTL Tuning — choosing the TTL for each tier and when
immutableis genuinely safe - CI/CD Asset Pipeline Integration — building once and promoting one artifact so every instance agrees on the build identifier