Next.js Static Asset Handling

Next.js splits asset delivery across two fundamentally different subsystems — one that auto-hashes, one that does not — and getting immutable CDN caching right requires understanding exactly where that boundary falls.

When to Use This Configuration vs Alternatives

Next.js handles its own fingerprinting through Webpack (or Turbopack in Next 15+), which makes it the correct choice when your project is already committed to the framework. Consider these decision points before investing in the configuration below:

  • Use Next.js native hashing when you have a server-rendered or hybrid Next.js application that deploys to a Node.js host, Vercel, or a containerized environment.
  • Use output: 'export' static mode when you need a fully static site (no Node.js runtime) deployed to S3, Cloudflare Pages, or a static CDN.
  • Use Vite instead when you are building a pure SPA without the Next.js routing model; Vite Asset Pipeline Configuration is simpler to configure and produces smaller build overhead.
  • Use Webpack directly when you have a custom SSR framework that wraps Webpack but is not Next.js; the Webpack Output Hashing Setup guide covers the raw configuration in detail.
  • Do not use Next.js native image optimization (next/image) if your deployment target cannot run a Node.js server; in output: 'export' mode you must supply a custom image loader.

The central limitation: files placed in public/ receive no automatic fingerprinting regardless of which build mode you use. Everything in this guide applies to compiler-processed assets only. For a detailed breakdown of why this split exists, see Next.js Asset Folder vs Public Directory Hashing.

Prerequisites

Requirement Minimum Notes
Next.js 14.0.0 App Router or Pages Router; examples tested on 14.2.x
Node.js 18.17.0 Required by Next.js 14
next CLI same as Next.js version npx next build or npm run build
Webpack 5.x (bundled) Do not install Webpack separately; Next.js manages the version
Turbopack opt-in via --turbopack flag Not yet stable for production in Next 14; test before enabling
CDN any Examples use generic HTTP headers; Cloudflare-specific purge shown in CI section

Enable experimental Turbopack in development only:

next dev --turbopack

For production builds, next build continues to use Webpack 5 as of Next.js 14.

Configuration Reference

Key Type Default Effect
assetPrefix string '' Prepends this URL to all /_next/static/* references in generated HTML. Use for CDN subdomain or path prefix.
output 'standalone' | 'export' undefined 'standalone' emits a minimal Node.js server. 'export' produces fully static HTML with no server requirement.
generateBuildId () => string | Promise<string> random UUID Controls the build ID embedded in /_next/static/<buildId>/ path segments.
webpack (config, ctx) => config Next.js defaults Override Webpack output options including filename, chunkFilename, and optimization.* keys.
headers() () => Promise<Header[]> [] Define HTTP response headers matched against request paths; used to enforce Cache-Control: immutable at the framework level.
images.loader string 'default' Set to 'custom' when using output: 'export' with a third-party image CDN.
images.path string '/_next/image' Override the image optimization API path; set to your CDN endpoint when proxying image optimization.
trailingSlash boolean false Adds trailing slashes to all exported routes. Required by some static hosts (S3, GitHub Pages).
distDir string '.next' Override the build output directory. Useful in monorepos where multiple Next.js apps share a workspace root.

Step-by-Step Implementation

Step 1: Establish Deterministic Build IDs

Next.js stores compiled assets under /_next/static/<buildId>/. By default this is a random UUID that changes on every build, making the buildId itself the primary cache-busting mechanism for page bundles. Lock it to your git commit SHA so builds are reproducible:

// next.config.js
const { execSync } = require('child_process');

/** @type {import('next').NextConfig} */
const nextConfig = {
  generateBuildId: async () => {
    const sha = execSync('git rev-parse --short=8 HEAD').toString().trim();
    return sha;
  }
};

module.exports = nextConfig;

In CI environments where git may not be available (shallow clones, some container images), fall back to an environment variable:

generateBuildId: async () => {
  return process.env.GIT_COMMIT_SHA
    || execSync('git rev-parse --short=8 HEAD').toString().trim();
}

For monorepos with multiple Next.js apps, use a 12–16 character hash to reduce collision probability across concurrent deployments:

generateBuildId: async () => {
  return execSync('git rev-parse --short=12 HEAD').toString().trim();
}

Step 2: Configure Webpack Content Hashing

Next.js automatically applies [contenthash] to JS and CSS chunks through its internal Webpack configuration. Override the filename templates to enforce 8-character hashes and stabilize chunk identity:

// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
  generateBuildId: async () => {
    return process.env.GIT_COMMIT_SHA || 'local';
  },
  webpack: (config, { isServer, dev }) => {
    if (!isServer && !dev) {
      config.output.filename = 'static/chunks/[name].[contenthash:8].js';
      config.output.chunkFilename = 'static/chunks/[name].[contenthash:8].chunk.js';

      // Deterministic module and chunk IDs prevent hash churn
      // when unrelated files are added to the module graph
      config.optimization.moduleIds = 'deterministic';
      config.optimization.chunkIds = 'deterministic';

      // Separate the Webpack runtime into its own chunk so that
      // vendor bundles do not change hash when only app code changes
      config.optimization.runtimeChunk = 'single';
    }
    return config;
  }
};

module.exports = nextConfig;

The runtimeChunk: 'single' setting is the most important line for cache stability. Without it, the Webpack runtime is inlined into every entry chunk, causing all entry chunk hashes to change whenever any dependency is added or removed — even one with no relevance to a given route.

Step 3: Configure assetPrefix for CDN Delivery

When static assets are served from a different origin than your Next.js server (a CDN subdomain, an S3 bucket, or a path-based reverse proxy), set assetPrefix so Next.js generates correct URLs in <script> and <link> tags:

// next.config.js
const nextConfig = {
  assetPrefix: process.env.NEXT_PUBLIC_CDN_URL || '',

  // If your CDN serves assets at a path prefix (e.g. /static),
  // include the prefix here:
  // assetPrefix: 'https://cdn.example.com/static',

  generateBuildId: async () => process.env.GIT_COMMIT_SHA || 'local',

  webpack: (config, { isServer, dev }) => {
    if (!isServer && !dev) {
      config.output.filename = 'static/chunks/[name].[contenthash:8].js';
      config.output.chunkFilename = 'static/chunks/[name].[contenthash:8].chunk.js';
      config.optimization.moduleIds = 'deterministic';
      config.optimization.chunkIds = 'deterministic';
      config.optimization.runtimeChunk = 'single';
    }
    return config;
  }
};

module.exports = nextConfig;

assetPrefix does not affect public/ files. Images referenced via an <img src="/logo.svg"> tag continue to be requested from the page origin, not the CDN. Use next/image with a loader configuration, or handle public/ files separately with your CDN, to cover those paths.

Step 4: Apply Immutable Cache Headers via headers()

Configure Next.js to emit correct Cache-Control headers for assets it serves directly. This is essential for deployments that do not use an upstream reverse proxy with header injection:

// next.config.js
const nextConfig = {
  assetPrefix: process.env.NEXT_PUBLIC_CDN_URL || '',

  generateBuildId: async () => process.env.GIT_COMMIT_SHA || 'local',

  webpack: (config, { isServer, dev }) => {
    if (!isServer && !dev) {
      config.output.filename = 'static/chunks/[name].[contenthash:8].js';
      config.output.chunkFilename = 'static/chunks/[name].[contenthash:8].chunk.js';
      config.optimization.moduleIds = 'deterministic';
      config.optimization.chunkIds = 'deterministic';
      config.optimization.runtimeChunk = 'single';
    }
    return config;
  },

  async headers() {
    return [
      {
        // All fingerprinted static assets: JS, CSS, images, fonts
        source: '/_next/static/:path*',
        headers: [
          {
            key: 'Cache-Control',
            value: 'public, max-age=31536000, immutable'
          }
        ]
      },
      {
        // HTML entry points must never be cached immutably
        source: '/:path*',
        headers: [
          {
            key: 'Cache-Control',
            value: 'no-cache, must-revalidate'
          }
        ]
      },
      {
        // Public directory assets: conservative TTL, no immutable flag
        source: '/:file((?!_next).+\\.(?:png|jpg|jpeg|webp|svg|ico|gif|woff2|woff|ttf))',
        headers: [
          {
            key: 'Cache-Control',
            value: 'public, max-age=86400, must-revalidate'
          }
        ]
      }
    ];
  }
};

module.exports = nextConfig;

The headers() configuration is evaluated at request time by the Next.js server. If you are deploying behind Cloudflare or Nginx, apply the same cache rules at the edge level as well; the headers() config serves as the source of truth for self-hosted environments. The broader principles behind cache key design are covered in Cache Key Architecture.

Step 5: Configure output: 'export' for Static Deployments

When deploying to a CDN-hosted static environment (S3 + CloudFront, Cloudflare Pages, GitHub Pages), use Next.js static export mode:

// next.config.js
const nextConfig = {
  output: 'export',

  // Required: static export cannot use the default image optimization
  // API which requires a Node.js server.
  images: {
    unoptimized: true
    // Or use a remote loader:
    // loader: 'custom',
    // loaderFile: './src/lib/imageLoader.js',
  },

  // Trailing slash is required by most static file hosts
  trailingSlash: true,

  assetPrefix: process.env.NEXT_PUBLIC_CDN_URL || '',

  generateBuildId: async () => process.env.GIT_COMMIT_SHA || 'local'
};

module.exports = nextConfig;

Build and inspect the output:

next build
ls -la out/_next/static/
# Expect: chunks/ css/ media/ <buildId>/

Static export mode places all files under out/. The _next/static/ directory inside out/ contains hashed assets; HTML files are at the root. Upload out/ to your static host and configure the host to serve Cache-Control: public, max-age=31536000, immutable for everything under _next/static/.

Step 6: next/image Optimization Configuration

next/image provides automatic format conversion (WebP, AVIF) and responsive sizing. In server mode, optimization happens at request time through the /_next/image endpoint:

// src/components/HeroImage.tsx
import Image from 'next/image';

export function HeroImage() {
  return (
    <Image
      src="/hero.jpg"
      alt="Hero"
      width={1200}
      height={630}
      priority
      quality={85}
      sizes="(max-width: 768px) 100vw, 1200px"
    />
  );
}

The /_next/image endpoint generates a unique URL based on the source path, width, quality, and format. Responses include a content hash in the URL, so they can be cached with max-age=31536000, immutable at the CDN level.

For static export mode, use a custom loader that delegates to an external image CDN:

// src/lib/imageLoader.js
export default function cloudflareImageLoader({ src, width, quality }) {
  const params = new URLSearchParams({
    width: width.toString(),
    quality: (quality || 80).toString(),
    format: 'auto'
  });
  return `https://your-zone.com/cdn-cgi/image/${params}/${src}`;
}
// next.config.js (static export with custom loader)
const nextConfig = {
  output: 'export',
  images: {
    loader: 'custom',
    loaderFile: './src/lib/imageLoader.js'
  }
};

Asset Routing Diagram

Next.js asset routing: compiled assets vs public directory next build Webpack 5 _next/static/ chunks/[name].[hash:8].js contenthash ✓ max-age=31536000, immutable public/ logo.svg → /logo.svg no hash ✗ manual versioning required CDN / Edge _next/static → HIT public/ → revalidate assetPrefix rewrites URL compiled static Auto-hashed (immutable cache) No hash (manual cache control) CDN edge delivery
Next.js routes compiler-processed assets through _next/static/ with automatic content hashing. Files in public/ bypass the compiler and reach the CDN without fingerprinting.

Verification Commands

Run these checks after every build to confirm the pipeline is working correctly:

# 1. Verify hashes are present in output filenames
next build
find .next/static/chunks -name "*.js" | grep -E '\.[a-f0-9]{8}\.' | head -10

# 2. Confirm the build manifest maps routes to hashed assets
cat .next/build-manifest.json | python3 -m json.tool | grep -E "\.js|\.css" | head -20

# 3. Check that the build ID is your git SHA (not a UUID)
cat .next/BUILD_ID

# 4. Verify static CSS files are hashed
find .next/static/css -name "*.css" | grep -E '\.[a-f0-9]{8}\.' | head -5

# 5. Confirm pages-manifest exists (required for SSR routing)
test -f .next/server/pages-manifest.json && echo "OK" || echo "MISSING"

For deployed environments, verify headers from outside the server:

# Replace with your actual CDN URL and a known hashed filename
ASSET_URL="https://cdn.example.com/_next/static/chunks/main-a1b2c3d4.js"

curl -sI "$ASSET_URL" | grep -E "cache-control|x-cache|cf-cache-status|etag"
# Expected:
# cache-control: public, max-age=31536000, immutable
# x-cache: HIT  (or cf-cache-status: HIT after first request)

Test a second request to confirm the CDN is caching (not forwarding to origin on every request):

# Two sequential requests; the second should show cache HIT
for i in 1 2; do
  echo "Request $i:"
  curl -sI "$ASSET_URL" | grep -E "cf-cache-status|x-cache|age:"
  sleep 1
done

public/ Directory Caveats

The public/ directory is the most common source of cache-related incidents in Next.js deployments. Key facts:

  1. No automatic fingerprinting. Files are served verbatim at their root path. /public/logo.svg is served at /logo.svg with no hash in the URL.
  2. assetPrefix does not apply. Setting assetPrefix to a CDN URL does not redirect public/ references. HTML that hard-codes /logo.svg will request from the page origin.
  3. next/image does not hash source files. The image optimization endpoint generates a hashed URL for the optimized output, but the original source file in public/ remains unhashed. If you replace /public/hero.jpg with new content without renaming it, the CDN may continue serving the old version until the TTL expires or you issue a purge.
  4. Cache headers from headers() apply. The headers() configuration in next.config.js can target public/ files using path matchers. Use this to enforce short TTLs on files that change frequently.
  5. output: 'export' does not change this. Static export mode copies public/ to the out/ directory without hashing regardless of any configuration.

For a complete treatment of public/ fingerprinting strategies including query-string versioning and post-build rename scripts, see Next.js Asset Folder vs Public Directory Hashing.

Edge Cases & Known Issues

Issue Root Cause Resolution
All chunk hashes change when a single file is edited Webpack runtime embedded in entry chunks instead of isolated Add optimization.runtimeChunk: 'single' to the webpack config
assetPrefix causes double slashes in asset URLs (//cdn.example.com/) assetPrefix set to a value with a trailing slash Strip trailing slash: process.env.CDN_URL.replace(/\/$/, '')
output: 'export' breaks next/image with no error at build time Static export cannot run the Node.js image optimization server Set images: { unoptimized: true } or configure a custom loader
Non-deterministic hashes across identical source code Module graph ordering differs between CI runners due to filesystem sort order Enforce moduleIds: 'deterministic' and chunkIds: 'deterministic' in Webpack config
CSS hashes change even when CSS is unchanged CSS is extracted by mini-css-extract-plugin and its content hash includes JS chunk names Use [contenthash] (not [hash]) for CSS assetModuleFilename; ensure JS chunks are stable first
_next/static/ paths return 404 after deploying to a subpath assetPrefix not set or set to the wrong path Set assetPrefix to the full path prefix including scheme and host
Build fails in CI with git: not found in generateBuildId Shallow clone or container without git Read from process.env.GIT_COMMIT_SHA or process.env.GITHUB_SHA before falling back to git
Turbopack ignores custom webpack() config Turbopack uses its own configuration system Move Turbopack-specific options to the turbopack key in next.config.js; keep Webpack overrides under webpack

For rollback scenarios where a bad deployment must be reversed while preserving CDN cache integrity, see Rolling Back Next.js Static Assets After a Bad Deploy.

Performance Impact

Correct fingerprinting configuration produces measurable improvements in CDN efficiency and load time. These figures are illustrative benchmarks from typical production deployments:

Metric Without fingerprinting With fingerprinting
CDN cache hit ratio (static assets) 40–60% 95–99%
Origin requests per page load (repeat visitor) 5–12 0–1 (HTML only)
Time to First Byte (repeat visitor) 80–200ms 5–20ms (edge cache)
CDN bandwidth cost High (frequent origin fetches) Minimal after warm-up
Deployment risk (mixed-version assets) High Low (immutable by content)

The runtimeChunk: 'single' optimization has a secondary benefit: it reduces the number of hash changes per deployment. Without it, adding any new dynamic import in any route can cascade hash changes to unrelated chunks, invalidating CDN cache for assets that did not change. With it, only the runtime chunk and the directly modified chunk change hash.

For foundational principles on why deterministic build outputs are necessary for reliable immutable caching, see Deterministic Build Outputs.

Complete next.config.js Reference

A production-ready configuration combining all sections above:

// next.config.js
const { execSync } = require('child_process');

/** @type {import('next').NextConfig} */
const nextConfig = {
  // Pin build ID to git SHA for reproducible cache keys
  generateBuildId: async () => {
    return (
      process.env.GIT_COMMIT_SHA ||
      process.env.GITHUB_SHA ||
      execSync('git rev-parse --short=8 HEAD').toString().trim()
    );
  },

  // CDN base URL for all /_next/static/* references
  // Leave empty for same-origin deployments
  assetPrefix: (process.env.NEXT_PUBLIC_CDN_URL || '').replace(/\/$/, ''),

  // Webpack overrides for deterministic hashing
  webpack: (config, { isServer, dev }) => {
    if (!isServer && !dev) {
      config.output.filename = 'static/chunks/[name].[contenthash:8].js';
      config.output.chunkFilename = 'static/chunks/[name].[contenthash:8].chunk.js';
      config.optimization.moduleIds = 'deterministic';
      config.optimization.chunkIds = 'deterministic';
      config.optimization.runtimeChunk = 'single';
    }
    return config;
  },

  // Cache-Control headers for self-hosted deployments
  async headers() {
    return [
      {
        source: '/_next/static/:path*',
        headers: [
          { key: 'Cache-Control', value: 'public, max-age=31536000, immutable' }
        ]
      },
      {
        source: '/:path((?!_next).*)',
        headers: [
          { key: 'Cache-Control', value: 'no-cache, must-revalidate' }
        ]
      },
      {
        source: '/:file((?!_next).+\\.(?:png|jpg|jpeg|webp|svg|ico|gif|woff2|woff|ttf|otf))',
        headers: [
          { key: 'Cache-Control', value: 'public, max-age=86400, must-revalidate' }
        ]
      }
    ];
  }
};

module.exports = nextConfig;

For Cloudflare-specific cache purge rules that complement the headers() configuration above, see Cloudflare Cache Rules and Purge.

CI/CD Integration

Post-build verification and CDN purge steps should be part of every deployment pipeline:

# .github/workflows/deploy.yml
name: Build and Deploy

on:
  push:
    branches: [main]

jobs:
  build-deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 1

      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - run: npm ci

      - name: Build
        run: npm run build
        env:
          GIT_COMMIT_SHA: ${{ github.sha }}
          NEXT_PUBLIC_CDN_URL: ${{ vars.CDN_URL }}

      - name: Verify build ID matches commit SHA
        run: |
          BUILD_ID=$(cat .next/BUILD_ID)
          EXPECTED=$(echo "${{ github.sha }}" | cut -c1-8)
          if [ "$BUILD_ID" != "$EXPECTED" ]; then
            echo "ERROR: BUILD_ID $BUILD_ID does not match commit $EXPECTED"
            exit 1
          fi

      - name: Verify hashed assets exist
        run: |
          COUNT=$(find .next/static/chunks -name "*.js" | grep -cE '\.[a-f0-9]{8}\.' || true)
          if [ "$COUNT" -lt "1" ]; then
            echo "ERROR: No hashed JS chunks found in .next/static/chunks/"
            exit 1
          fi
          echo "Found $COUNT hashed JS chunks"

      - name: Upload static assets to CDN origin
        run: |
          aws s3 sync .next/static/ s3://${{ vars.ASSETS_BUCKET }}/_next/static/ \
            --cache-control "public, max-age=31536000, immutable" \
            --content-encoding identity
        env:
          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}

      - name: Purge CDN HTML cache (not static assets)
        run: |
          curl -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 '{"purge_everything": false, "tags": ["html"]}'
        env:
          CF_ZONE_ID: ${{ secrets.CF_ZONE_ID }}
          CF_API_TOKEN: ${{ secrets.CF_API_TOKEN }}

The pipeline deliberately does not purge _next/static/*. Those assets are immutable by content hash; the only way to access updated JS or CSS is through an updated HTML reference that points to the new hashed filename.

FAQ

Why does Next.js use both a buildId and a contenthash — aren’t they redundant?

They serve different purposes. The buildId appears in the URL segment /_next/static/<buildId>/pages/ and is used to namespace page-level bundles for a specific deployment. The contenthash is embedded in individual chunk filenames within /_next/static/chunks/ and changes only when the chunk’s source content changes. Using both means a rollback to a previous deployment uses the old buildId, which references the old set of chunks — even if those chunks are still on the CDN from a previous deployment.

Does generateBuildId affect contenthash values?

No. contenthash is derived solely from the module source content processed by Webpack. buildId only affects the directory segment in the URL path. Changing generateBuildId without changing source files will produce a different URL structure but identical contenthash values in filenames.

Can I use output: 'export' and next/image together without a custom loader?

Only if you set images: { unoptimized: true }. With this setting, next/image renders a standard <img> tag without optimization. The image is served from public/ as-is, with no hashing and no format conversion. For production use, configure a custom loader that delegates to an external image CDN.

How should I handle hash length in a monorepo with multiple Next.js apps?

Use 12–16 characters in generateBuildId and in the Webpack [contenthash:12] token. The default 8 characters gives 4.3 billion unique values, which is sufficient for a single app but leaves less margin when multiple apps deploy in parallel from the same repository and both write to a shared CDN path. Longer hashes reduce the chance of a collision in CDN cache key lookups.