Rolling Back Next.js Static Assets After a Bad Deploy
Your monitoring fires at 02:14. JS errors spiking across the fleet. Users mid-session are getting ChunkLoadError: Loading chunk 47 failed or a blank white screen. A bad deployment just landed, and the first instinct is to roll back — but if you handle this wrong you will create a second failure mode on top of the first. This guide covers exactly how to execute a safe, verified rollback without breaking users who are already mid-session on the previous build.
The decision split is straightforward: if the regression is in application logic (wrong behavior, data corruption, API mismatch) and can be forward-fixed within 15 minutes, fix forward. If the regression is in the compiled asset graph itself (webpack chunk errors, broken CSS extraction, bad runtime polyfills, a hydration mismatch that crashes the root layout), roll back. This page covers the rollback path.
How Next.js Organizes _next/static/
Before you can execute a rollback, you need to understand exactly what you are rolling back. Next.js writes its compiled output into .next/ during next build. When deployed, the critical directory for browser-served assets is _next/static/, which maps directly to .next/static/ on origin.
That directory has this structure:
_next/static/
├── <buildId>/ ← route-specific page bundles
│ ├── _buildManifest.js
│ └── _ssgManifest.js
├── chunks/ ← shared webpack chunks (framework, vendor, app)
│ ├── main-a1b2c3d4.js
│ ├── webpack-e5f6a7b8.js
│ └── pages/
│ ├── index-9c0d1e2f.js
│ └── about-3a4b5c6d.js
├── css/
│ └── 7e8f9a0b.css
└── media/
└── logo.1c2d3e4f.svg
The <buildId> segment is a unique identifier generated per build. It is not a content hash of your source — it is typically derived from a git commit SHA or an internal build counter. Next.js writes this value to .next/BUILD_ID as a plain text file.
cat .next/BUILD_ID
# e.g.: abc123def456789012345678
When the browser loads a Next.js page, the HTML returned by the server embeds script tags pointing to /_next/static/<buildId>/_buildManifest.js. That manifest tells the client-side router exactly which chunk files (under _next/static/chunks/) correspond to each route. The chunks directory itself is not namespaced by buildId — chunk files are named by their content hash. This distinction matters for rollback: chunk filenames are stable if source content did not change, but the manifest file that references them is tied to <buildId>.
If a user opened the page during build A, their browser has cached the build A manifest and expects specific chunk hashes. If you deploy build B and delete build A’s _next/static/<buildIdA>/ directory, that user’s next navigation triggers a request for /_next/static/<buildIdA>/_buildManifest.js or a chunk that no longer exists — resulting in a 404 and a broken session.
See Next.js asset folder vs public directory hashing for the complementary concern: public/ assets are a separate concern and need their own purge strategy during rollback.
The Atomic Deploy + Rollback Pattern
The correct deployment model keeps the last two to three builds present on origin simultaneously, with a symlink pointing to the current release. This way, in-flight sessions referencing a previous buildId continue to receive valid 200 responses for up to the session-window duration (typically five to ten minutes after the prior deploy).
/srv/
└── app/
├── releases/
│ ├── 20260618-142300/ ← build A (previous, keep alive)
│ │ └── .next/
│ │ ├── BUILD_ID ← "abc123de"
│ │ └── static/
│ └── 20260620-021000/ ← build B (bad, current)
│ └── .next/
│ ├── BUILD_ID ← "f9e8d7c6"
│ └── static/
└── current -> releases/20260618-142300/ ← symlink
The web server (Nginx, Caddy, or your Node process manager) serves _next/static/ from the current symlink target. Swapping current to a prior release is an atomic operation — the symlink swap is instantaneous with no intermediate state.
Merging the static directories from all live releases into a single _next/static/ mount point is an alternative approach suitable for CDN-origin architectures. You rsync each build’s .next/static/ into a shared directory on S3 or R2, and the CDN origin always points there. Because chunk filenames are content-hashed, files from build A and build B coexist without collision (unless the same chunk content changed, in which case the hash differs).
CDN Purge Strategy During Rollback
The most common mistake during a rollback is purging _next/static/* from the CDN. Do not do this.
Hashed static assets (_next/static/chunks/, _next/static/css/, _next/static/media/) must never be purged during a rollback. These files are immutable by content hash — the CDN serving a cached copy of chunks/main-a1b2c3d4.js is the correct behavior. Purging them forces a cache miss on every request and adds latency during an already-degraded incident window.
What you must purge is the HTML entry points — your root /, /about, /dashboard, and any other server-rendered pages. These are what embed the buildId reference in their <script> tags. Once the rollback symlink is in place and your Node process is serving build A’s HTML, the CDN needs to evict its cached copies of build B’s HTML.
You can also avoid purging the _next/static/<buildIdB>/ manifest files if you are confident no user has a build B session open (in practice, purging _next/static/<buildId>/ for the specific bad buildId is safe and cleans up edge storage, but it is not required).
This selective purge strategy aligns with rolling back a bad asset deploy on Cloudflare and the broader pattern documented in rolling back cache keys after a bad deploy.
| Path pattern | Purge during rollback? | Reason |
|---|---|---|
/ /about /dashboard etc. |
Yes — always | HTML embeds buildId, must reflect rolled-back version |
/_next/static/<badBuildId>/ |
Optional — safe to purge | Cleans up stale manifest; sessions on bad build are already broken |
/_next/static/chunks/* |
No | Content-hashed, immutable — purging adds unnecessary cache misses |
/_next/static/css/* |
No | Same as chunks |
/_next/static/media/* |
No | Same as chunks |
/logo.svg /favicon.ico (public/) |
Only if changed in bad deploy | See public/ section below |
Step-by-Step Rollback Procedure
Step 1 — Identify the Bad Build
Check your error monitoring for chunk load failures or hydration errors tied to the new buildId:
#!/bin/bash
# Confirm current deployed buildId from origin
ORIGIN_HOST="your-origin.internal"
CURRENT_BUILD_ID=$(curl -sf "http://${ORIGIN_HOST}/.next/BUILD_ID")
echo "Currently serving buildId: ${CURRENT_BUILD_ID}"
# Check which release directory maps to current
ls -la /srv/app/current
# e.g.: /srv/app/current -> /srv/app/releases/20260620-021000
Cross-reference the buildId with your artifact store to confirm which git SHA produced it. Most incident diagnosis will start from Sentry breadcrumbs or log aggregation — look for ChunkLoadError, Loading CSS chunk, or SyntaxError spikes aligned with the deployment timestamp.
Step 2 — Identify the Previous Good Build
#!/bin/bash
# List releases in chronological order
ls -lt /srv/app/releases/ | head -5
# Read the buildId from the candidate previous release
PREV_RELEASE="/srv/app/releases/20260618-142300"
PREV_BUILD_ID=$(cat "${PREV_RELEASE}/.next/BUILD_ID")
echo "Previous release buildId: ${PREV_BUILD_ID}"
If you are using an artifact store (S3, GitHub Actions artifacts, your CI system’s storage), retrieve and unpack the previous build artifact into /srv/app/releases/<timestamp>/ before proceeding.
Step 3 — Execute the Symlink Swap
The symlink swap is atomic at the filesystem level. There is no window where current points to nothing.
#!/bin/bash
set -euo pipefail
RELEASES_DIR="/srv/app/releases"
CURRENT_LINK="/srv/app/current"
TARGET_RELEASE="${RELEASES_DIR}/20260618-142300"
# Verify target release exists and has a valid BUILD_ID
if [[ ! -f "${TARGET_RELEASE}/.next/BUILD_ID" ]]; then
echo "ERROR: Target release missing BUILD_ID. Aborting." >&2
exit 1
fi
TARGET_BUILD_ID=$(cat "${TARGET_RELEASE}/.next/BUILD_ID")
echo "Rolling back to buildId: ${TARGET_BUILD_ID}"
# Atomic symlink swap using ln -sfn (no intermediate broken state)
ln -sfn "${TARGET_RELEASE}" "${CURRENT_LINK}"
echo "Symlink updated: ${CURRENT_LINK} -> ${TARGET_RELEASE}"
# Signal Node process manager to reload (pick one based on your setup)
# pm2 reload all
# systemctl reload app
# kill -USR2 $(cat /var/run/app.pid)
For rsync-based deploys where you merge static directories into a shared mount, ensure the previous build’s _next/static/<prevBuildId>/ directory was preserved on your origin store:
#!/bin/bash
set -euo pipefail
# Ensure previous build's static manifest is available on origin
PREV_BUILD_ID="abc123de"
STATIC_STORE="/srv/static-store"
if [[ ! -d "${STATIC_STORE}/_next/static/${PREV_BUILD_ID}" ]]; then
echo "ERROR: Previous buildId directory not found in static store. Cannot roll back cleanly." >&2
exit 1
fi
echo "Previous buildId directory confirmed present. Proceeding with rollback."
Step 4 — Purge HTML Entry Points from CDN
Once origin is serving build A’s HTML, purge only the HTML routes. Here is a Cloudflare example; adapt the endpoint for your CDN:
#!/bin/bash
set -euo pipefail
CF_ZONE_ID="${CF_ZONE_ID:?CF_ZONE_ID must be set}"
CF_API_TOKEN="${CF_API_TOKEN:?CF_API_TOKEN must be set}"
DOMAIN="www.example.com"
# Purge only HTML entry points — NOT _next/static/*
curl -sf -X POST \
"https://api.cloudflare.com/client/v4/zones/${CF_ZONE_ID}/purge_cache" \
-H "Authorization: Bearer ${CF_API_TOKEN}" \
-H "Content-Type: application/json" \
-d "{
\"files\": [
\"https://${DOMAIN}/\",
\"https://${DOMAIN}/about\",
\"https://${DOMAIN}/dashboard\",
\"https://${DOMAIN}/login\"
]
}" | jq '.success'
If you have many routes, use a tag-based purge if your CDN supports it, or use a wildcard purge for the bare domain with a specific Cache-Tag header set on HTML responses. Do not use a wildcard that would catch _next/static/ paths.
Step 5 — Verify Chunk Availability for Previous buildId
#!/bin/bash
set -euo pipefail
DOMAIN="www.example.com"
# Read the now-active buildId from origin directly
ACTIVE_BUILD_ID=$(curl -sf "https://${DOMAIN}/.next/BUILD_ID" 2>/dev/null \
|| curl -sf "http://your-origin.internal/.next/BUILD_ID")
echo "Active buildId: ${ACTIVE_BUILD_ID}"
# Fetch the build manifest to get chunk references
MANIFEST_URL="https://${DOMAIN}/_next/static/${ACTIVE_BUILD_ID}/_buildManifest.js"
MANIFEST_STATUS=$(curl -o /dev/null -sw "%{http_code}" "${MANIFEST_URL}")
echo "Build manifest status: ${MANIFEST_STATUS} (expect 200)"
# Spot-check a known chunk from the previous build
# Get chunk filename from the build manifest JSON (adjust path for your build)
CHUNK_PATH=$(cat .next/build-manifest.json | jq -r '.pages["/"] | .[0]' 2>/dev/null || echo "chunks/main-abc123de.js")
CHUNK_STATUS=$(curl -o /dev/null -sw "%{http_code}" "https://${DOMAIN}/_next/static/${CHUNK_PATH}")
echo "Chunk ${CHUNK_PATH} status: ${CHUNK_STATUS} (expect 200)"
The single targeted verification command to run from your terminal during the incident:
curl -o /dev/null -sw "buildManifest: %{http_code}\n" \
"https://www.example.com/_next/static/$(curl -sf https://www.example.com/ | grep -oP '(?<=/_next/static/)[^/]+(?=/_buildManifest)' | head -1)/_buildManifest.js"
public/ Assets During Rollback
The public/ directory is a separate concern from _next/static/. Files in public/ are not content-hashed by Next.js — they are served at their literal path (/logo.svg, /favicon.ico, /robots.txt). This means:
- If the bad deploy changed a
public/file, you must explicitly purge that path from the CDN after rollback. - If the bad deploy did not touch
public/, nopublic/purge is needed. - Because these files have no hash in their URL, rolling back the origin alone is not enough — cached CDN copies of the bad version will continue serving until purged.
See Next.js asset folder vs public directory hashing for the full breakdown of the public/ cache invalidation lifecycle.
#!/bin/bash
# Purge specific public/ files that changed in the bad deploy
CF_ZONE_ID="${CF_ZONE_ID:?}" CF_API_TOKEN="${CF_API_TOKEN:?}"
DOMAIN="www.example.com"
curl -sf -X POST \
"https://api.cloudflare.com/client/v4/zones/${CF_ZONE_ID}/purge_cache" \
-H "Authorization: Bearer ${CF_API_TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"files\": [\"https://${DOMAIN}/logo.svg\", \"https://${DOMAIN}/favicon.ico\"]}" \
| jq '.success'
Comparison: Rollback vs Forward Fix
| Criterion | Roll Back | Forward Fix |
|---|---|---|
| Root cause in compiled assets (chunk errors, bad CSS) | Preferred — restores known-good artifact | Risky — must rebuild and redeploy under pressure |
| Root cause in app logic / API contract | Riskier — prior code may also be incompatible | Preferred — targeted patch deploy |
| Severity: full white screen for all users | Roll back immediately | Acceptable only if fix is < 5 min |
| Artifact store has previous build | Must have — verify before starting | Not needed |
| CDN has cached bad HTML | Requires HTML purge after rollback | Requires HTML purge after fix |
| In-flight users on previous buildId | Safe — keep previous static dir | Safe — keep current static dir |
| Time to recovery | 2-5 min (symlink + purge) | 8-20 min (fix + build + deploy + purge) |
| Database/schema dependency | Check — rolled-back code must be compatible with current DB state | No rollback risk |
When to Reconsider Rollback
When does forward-fix beat rollback?
If your bad deploy included a database migration that is not backward-compatible — new NOT NULL columns, renamed tables, dropped columns — the previous build’s code may not run against the current schema. Rolling back the Next.js asset would reintroduce the incompatible ORM queries. In this case, forward-fix with a hotfix deploy is mandatory. Rollback is only safe when the underlying data layer is unchanged or backward-compatible.
A second scenario where rollback is counterproductive: if the previous build itself had a security vulnerability that you just patched. Monitoring firing on build B does not mean build A was safe — verify your release notes before restoring an older artifact.
Finally, if you are on a platform-managed deployment (Vercel, Netlify, Railway) with automatic atomic deploys and rollback buttons, use the platform’s rollback primitive. The platform retains prior build artifacts and handles the static directory coexistence automatically. The manual procedures above apply to self-hosted Node or Docker-based deployments where you manage the artifact lifecycle yourself. For the full CI/CD integration pattern, see rolling back fingerprinted assets in CI/CD.
Frequently Asked Questions
How long do in-flight sessions need the previous buildId’s chunks to remain available?
The safe window is the longest plausible session that a user could have open without a hard page reload. In practice, five to ten minutes covers the vast majority of users. If your product has long-lived single-page sessions (dashboards, editors), extend this to thirty minutes. The cost of keeping the extra directory on origin is negligible; the cost of a 404 during an incident is significant.
Can I delete the bad build’s _next/static/<buildId>/ directory immediately after rollback?
Not if users might still have the bad HTML cached in their browser. The bad HTML references the bad buildId manifest. If a user reloads their tab before the CDN purge propagates, their browser serves them the still-cached bad HTML, which then requests the bad buildId manifest. Keep _next/static/<badBuildId>/ on origin for at least fifteen minutes after the CDN purge reports complete.
What hash length should I use in my chunk filenames?
Next.js defaults to eight hex characters ([contenthash:8]), which gives 4.3 billion possible values — sufficient for single apps. For monorepos building multiple apps from the same webpack process, use 12-16 characters to reduce the probability of a hash collision across the combined output set.
Does the rollback procedure differ for Next.js App Router vs Pages Router?
The _next/static/ layout and buildId mechanics are identical between routers. The App Router adds server component payloads (.next/server/app/) and RSC chunks, but these are server-side concerns. Browser-facing static assets behave the same way, and this rollback procedure applies to both.
How do I confirm the CDN actually served the purge and is not still caching the bad HTML?
Run curl -sI https://www.example.com/ | grep -E "CF-Cache-Status|Age|buildId". After a successful Cloudflare purge, the first request returns CF-Cache-Status: MISS and Age: 0. A lingering Age: 847 with HIT means propagation is still in progress or the purge did not target that PoP.
Related
- Next.js Static Asset Handling — parent overview covering assetPrefix configuration, build-time fingerprinting, and CDN integration workflows
- Next.js Asset Folder vs Public Directory Hashing — why
public/assets need a separate purge strategy and how to align their invalidation with release cycles - Rolling Back Fingerprinted Assets in CI/CD — artifact retention policies, pipeline-level rollback automation, and how to wire previous-build preservation into GitHub Actions or GitLab CI
- Rolling Back a Bad Asset Deploy on Cloudflare — CDN-specific purge commands, Cache Rules configuration, and PoP propagation verification for Cloudflare deployments
- Rolling Back Cache Keys After a Bad Deploy — foundational concepts for cache key design that make rollbacks safe and predictable