Next.js Asset Folder vs Public Directory Hashing: CDN Cache Invalidation Guide
Deployment failures in Next.js applications frequently trace back to one structural asymmetry: the _next/static/ directory receives automatic content hashing, while public/ files get none. A JavaScript bundle imported via import styles from './app.css' emerges from the build as _next/static/css/app.a3f9c21b.css, cached immutably at the CDN edge forever. The logo.png sitting in public/ is still served at /logo.png with no fingerprint, and whatever Cache-Control policy your CDN or reverse proxy applies to that path determines whether users see the updated file in two seconds or two weeks.
Understanding the exact mechanics of Next.js Static Asset Handling is prerequisite to resolving these mismatches. This guide covers symptom identification via HTTP headers, a precise comparison of both asset systems, concrete next.config.js and Nginx configurations, and the decision logic for when each approach is correct.
Symptom Identification and Cache Behavior Analysis
Post-deployment stale assets and 404 errors produce distinct HTTP header signatures. Identify which system is misbehaving before applying any fix.
Verification command — run against every affected asset path:
curl -sI https://your-domain.com/logo.png | grep -E "^(Cache-Control|ETag|CF-Cache-Status|X-Cache|Age):"
Cross-reference the output against this matrix:
| Header State | Observed Symptom | Root Cause |
|---|---|---|
Cache-Control: max-age=31536000, immutable on an unversioned public path |
Stale content served indefinitely | Public file cached with immutable TTL; requires explicit CDN purge on every deploy |
404 Not Found on a hashed _next/static/ path |
Broken JS or CSS, hydration failure | Previous build artifacts deleted from origin before CDN TTL expired |
X-Cache: HIT + stale ETag |
Hydration mismatch between server HTML and loaded JS | Client received old bundle; server rendered with new data contracts |
Age: 0 + Cache-Control: no-cache on _next/static/ |
Unnecessary origin load on every request | immutable directive absent; CDN revalidates on each request despite content hash guaranteeing no change |
Stale-while-revalidate gaps and multi-region propagation delays typically resolve within 30–60 seconds. If the problem persists beyond five minutes, it is a configuration issue, not propagation lag.
Precise Mechanics: How Each System Works
_next/static/ — Compiler-Controlled, Always Hashed
When Next.js processes a file that is imported somewhere in the application graph, Webpack or Turbopack runs it through the asset pipeline. The output filename receives a content hash derived from the file’s bytes:
.next/static/chunks/pages/index-a3f9c21b.js
.next/static/css/app-7e4d0f3c.css
.next/static/media/font-bold-c8b1a92e.woff2
The default hash length is 8 hex characters. For monorepos where multiple packages may produce chunks with similar internal content but different provenance, increase to 12–16 characters in next.config.js:
// next.config.js — extend hash length for monorepo builds
/** @type {import('next').NextConfig} */
module.exports = {
generateBuildId: async () => {
return require('crypto')
.createHash('sha256')
.update(Date.now().toString())
.digest('hex')
.slice(0, 16);
},
};
These files are immutable by definition: the same content always produces the same hash, and a changed file always produces a different hash. Next.js emits them with Cache-Control: public, max-age=31536000, immutable. CDN edges should cache them indefinitely without revalidation.
public/ — Bypasses the Compiler Entirely
Files in public/ are copied to the output as-is and served at the root URL path. public/logo.png becomes /logo.png. There is no hash, no content processing, no versioning applied by the framework.
Next.js sets no special Cache-Control on these paths by default. The CDN or reverse proxy applies whatever default TTL is configured at the edge, which on many platforms defaults to following the origin response headers — and if your origin sends no headers, some CDNs fall back to heuristic caching that can reach hours or days.
The reference assembly point (Build Tool and Framework Asset Pipeline Integration) documents how this maps to Webpack output config and contenthash tokens. The public/ directory is entirely outside that system.
Side-by-Side Comparison
| Dimension | _next/static/ |
public/ |
|---|---|---|
| Fingerprinting | Automatic content hash (8 hex chars default) | None — filename unchanged across deploys |
| URL pattern | /_next/static/chunks/page-a3f9c21b.js |
/logo.png, /favicon.ico |
| Cache-Control emitted | public, max-age=31536000, immutable |
None by default; must be set explicitly |
| CDN invalidation needed | Never (new content = new URL) | Yes, on every deploy that changes the file |
| Rollback behavior | Old URLs still resolve if artifacts retained | Old content at same URL; CDN may serve cached version |
next/image optimization |
Available for imported images | Available when src="/path" is passed explicitly |
| How to update strategy | Update content, rebuild — hash changes | Query-string version, short TTL, or CDN purge |
| Suitable for | JS bundles, CSS, fonts, imported images | Robots.txt, sitemaps, favicons, OG images, PDF downloads |
Decision Flow Diagram
_next/static/; public/ files require explicit versioning or short TTL configuration.Resolution Strategies
Strategy A: Query-String Versioning with NEXT_PUBLIC_BUILD_ID
Append a build identifier to every public/ reference. CDNs treat ?v=abc123 as a distinct cache key from ?v=def456, forcing revalidation on each deploy without changing the physical file path.
// components/Logo.tsx
const buildId = process.env.NEXT_PUBLIC_BUILD_ID ?? 'dev';
export function Logo() {
return (
<img
src={`/logo.png?v=${buildId}`}
alt="Brand logo"
width={120}
height={40}
/>
);
}
Set NEXT_PUBLIC_BUILD_ID in your CI pipeline:
# .env.production (generated during CI build step)
NEXT_PUBLIC_BUILD_ID=a3f9c21b
Or derive it from git at build time in next.config.js:
// next.config.js
const { execSync } = require('child_process');
const buildId = execSync('git rev-parse --short=8 HEAD').toString().trim();
/** @type {import('next').NextConfig} */
module.exports = {
env: {
NEXT_PUBLIC_BUILD_ID: buildId,
},
};
For monorepos producing many bundles with potential hash overlap, use 12–16 character hashes: --short=12 or --short=16 in the git command.
Query-string versioning maps directly to the patterns documented in implementing cache keys with query parameters vs filenames. The tradeoff is that filenames remain identical across deploys, so any Cloudflare or Nginx rule that strips query strings from cache keys will silently break this strategy — verify CDN configuration before relying on it.
Strategy B: Explicit Cache-Control Headers for public/ Assets
Set short TTLs on public asset paths in next.config.js. The headers() function applies response headers to URL patterns at the Next.js server level before CDN caching occurs:
// next.config.js
/** @type {import('next').NextConfig} */
module.exports = {
async headers() {
return [
{
// _next/static/ — immutable forever, content hash guarantees freshness
source: '/_next/static/:path*',
headers: [
{
key: 'Cache-Control',
value: 'public, max-age=31536000, immutable',
},
],
},
{
// public/ images served at root — short TTL, must revalidate
source: '/:filename((?!_next).+\\.(?:png|jpg|jpeg|svg|ico|gif|webp))',
headers: [
{
key: 'Cache-Control',
value: 'public, max-age=86400, must-revalidate',
},
],
},
{
// public/ fonts — medium TTL, not immutable since filenames don't change
source: '/:filename((?!_next).+\\.(?:woff|woff2|ttf|otf))',
headers: [
{
key: 'Cache-Control',
value: 'public, max-age=604800, stale-while-revalidate=86400',
},
],
},
];
},
};
The pattern (?!_next) is critical: it prevents the rule from accidentally overriding the immutable headers Next.js sets on _next/static/ assets.
The relationship between immutable, ETag, and stale-while-revalidate is covered in depth at ETag vs immutable Cache-Control for assets.
Nginx Gateway Configuration
Apply equivalent rules at the reverse proxy when Next.js runs behind Nginx. Nginx rules execute before Next.js headers, so ensure they do not conflict:
# /etc/nginx/sites-available/your-site.conf
server {
listen 443 ssl;
server_name your-domain.com;
# Immutable hashed assets — cache forever at edge and browser
location ~* ^/_next/static/ {
proxy_pass http://nextjs_upstream;
add_header Cache-Control "public, max-age=31536000, immutable";
add_header Vary "Accept-Encoding";
proxy_cache_valid 200 365d;
}
# Public directory images — short TTL, always revalidate
location ~* ^/(?!_next/).+\.(png|jpg|jpeg|svg|ico|gif|webp)$ {
proxy_pass http://nextjs_upstream;
add_header Cache-Control "public, max-age=86400, must-revalidate";
add_header Vary "Accept-Encoding";
proxy_cache_valid 200 1d;
}
# Public directory fonts — medium TTL
location ~* ^/(?!_next/).+\.(woff|woff2|ttf|otf)$ {
proxy_pass http://nextjs_upstream;
add_header Cache-Control "public, max-age=604800, stale-while-revalidate=86400";
proxy_cache_valid 200 7d;
}
}
Cloudflare Cache Rules
In the Cloudflare dashboard under Caching > Cache Rules, create rules in this order (earlier rules take precedence):
- Rule: Next.js static assets — URI path matches
/_next/static/*→ Cache status: Cache, Edge TTL: 1 year, Browser TTL: 1 year, Respect origin headers: off - Rule: Public images — URI path matches
*.pngOR*.jpgOR*.svgOR*.icoAND does NOT match/_next/*→ Cache status: Cache, Edge TTL: 1 day, Browser TTL: 1 day
For purge automation on deploy, trigger a tagged cache purge via the Cloudflare API. Tag public assets with a Cache-Tag: public-static response header (set this in next.config.js headers), then purge by tag on each deploy:
#!/usr/bin/env bash
set -euo pipefail
BUILD_ID=$(cat .next/BUILD_ID)
echo "Deploying build: ${BUILD_ID} — purging public-static cache tag"
curl -sX POST \
"https://api.cloudflare.com/client/v4/zones/${CF_ZONE_ID}/purge_cache" \
-H "Authorization: Bearer ${CF_API_TOKEN}" \
-H "Content-Type: application/json" \
--data '{"tags": ["public-static"]}' \
| jq '.success'
next/image vs Raw <img> for Public Directory Files
The choice of element determines whether Next.js optimization pipeline applies:
next/image with src="/logo.png" passes the image through the Next.js image optimization API at request time (/_next/image?url=%2Flogo.png&w=256&q=75). The optimized output is cached with content-based ETags. The source file in public/ still has no hash, but the served URL does receive caching from Next.js’s image cache layer.
Raw <img src="/logo.png"> serves the file directly from public/ with no intermediate processing. Any Cache-Control header applied via next.config.js headers rules or Nginx applies directly to /logo.png.
Use next/image when: the image dimensions and quality need responsive optimization, you want automatic WebP/AVIF conversion, or you need lazy loading with layout shift prevention.
Use raw <img> when: the image is an inline SVG icon with known fixed dimensions, you are serving a file that must not be transformed (e.g., a signature image, a print-quality asset), or you are using a CDN image transformation service outside Next.js.
For next/image with external images or when you want to control the hash-based URL yourself, pass a statically imported image object:
// next/image with a statically imported file — gets a content hash URL
import logoSrc from '../public/logo.png';
import Image from 'next/image';
export function Logo() {
return <Image src={logoSrc} alt="Brand logo" width={120} height={40} />;
}
When imported this way, Next.js includes the image in the asset pipeline and the src attribute resolves to a hashed _next/static/media/logo-a3f9c21b.png URL, bypassing the public/ limitation entirely.
Verification Workflow
After deploying a configuration change, verify each asset class independently:
# 1. Confirm _next/static/ assets carry immutable headers
curl -sI https://your-domain.com/_next/static/chunks/main-a3f9c21b.js \
| grep -E "^(Cache-Control|CF-Cache-Status|Age):"
# Expected:
# Cache-Control: public, max-age=31536000, immutable
# CF-Cache-Status: HIT
# Age: 42
# 2. Confirm public/ images carry short TTL headers
curl -sI https://your-domain.com/logo.png \
| grep -E "^(Cache-Control|CF-Cache-Status|ETag):"
# Expected:
# Cache-Control: public, max-age=86400, must-revalidate
# CF-Cache-Status: HIT
# ETag: "a1b2c3d4..."
# 3. Confirm query-string versioning changes cache key
curl -sI "https://your-domain.com/logo.png?v=a3f9c21b" \
| grep "^CF-Cache-Status:"
# First hit: MISS, subsequent: HIT — confirms CDN treats ?v= as part of key
When to Reconsider
Migrate a public/ asset into src/ when:
- The asset changes with every build (theme tokens, generated images, dynamic manifests)
- You are running a monorepo and hash collision risk is real
- 404s during rollback windows exceed your SLA tolerance — see rolling back Next.js static assets after a bad deploy for the artifact retention strategy that makes
_next/static/rollback safe
Keep an asset in public/ when:
- It must be served at a predictable, unchanging URL (robots.txt, sitemap.xml,
/.well-known/paths, favicon.ico for legacy browser compatibility) - External services reference the URL directly without query-string support (some RSS readers, email clients)
- The file is truly static and never changes across deploys (open-graph fallback image, legal PDF)
Reconsider query-string versioning when:
- Your CDN strips query strings from cache keys (Fastly default behavior, some Cloudflare configurations) — file-based naming wins here
- You operate behind a shared reverse proxy that normalizes URLs before forwarding — query strings may be dropped silently
Frequently Asked Questions
Does Next.js automatically hash files placed in the public directory?
No. The public/ directory bypasses Webpack and Turbopack entirely. Files are copied verbatim to the output and served at their original path. Only files that are imported in JavaScript or TypeScript source receive content hashes.
Can I add content hashes to public directory files without moving them to src/?
Not with Next.js built-in tooling. You can write a post-build script that reads files from public/, computes SHA-256 hashes, copies them to public/dist/hashed-name-a3f9c21b.ext, and generates a manifest JSON that your components reference at runtime. This is effective but adds build pipeline complexity. The simpler path for most assets is a static import from src/.
Why does next/image with a /public path still miss content hashing?
next/image src="/logo.png" fetches from the public directory at request time and applies optimization, but the original /logo.png URL remains unhashed. The optimized derivative at /_next/image?url=... is cached with ETags, but if you replace logo.png in public/ and deploy, the CDN may still serve the cached derivative unless you purge the image optimization cache separately via /_next/image routes.
What happens to _next/static/ assets during a rollback?
If you retained the previous build artifacts on the origin, old hashed URLs continue to resolve. CDN edges serving cached copies of the old assets will eventually revalidate and receive 404s if artifacts are deleted prematurely. The safe minimum is retaining the previous two builds on the origin for at least 24 hours post-deploy. Detailed artifact management is covered in rolling back Next.js static assets after a bad deploy.
How do I confirm Cloudflare is using query strings as part of the cache key?
In Cloudflare’s Cache Rules, the default behavior includes query strings in the cache key. Verify by checking Caching > Configuration > Cache Key in the dashboard, or by running two sequential curls with different ?v= values and confirming the first returns CF-Cache-Status: MISS and the second returns HIT. If both return MISS, query strings are being stripped and you need file-based naming instead.
Related
- Next.js Static Asset Handling — parent reference covering the full build output structure,
generateBuildId, and CDN integration patterns - Rolling Back Next.js Static Assets After a Bad Deploy — artifact retention strategy and CDN cache coordination for safe rollbacks
- Implementing Cache Keys with Query Parameters vs Filenames — when query-string versioning is the right choice and when content-hashed filenames win
- ETag vs Immutable Cache-Control for Assets — header-level mechanics behind
immutable,must-revalidate, andstale-while-revalidate