How to Configure Content Hashing in Vite Production Builds

After a deployment your CDN serves the previous version of main.js to users who visited the site in the last year. The page loads, React initializes, a lazy import fires — and a 404 drops because the old chunk filename no longer exists on the origin. This is not a CDN misconfiguration. It is a missing or incorrectly wired content hash in your Vite build.

Content hashing is the contract that makes aggressive caching safe. When every byte change in a file produces a new filename, cached copies never collide with fresh ones, and edge nodes can hold assets for a year without any manual purge. The Vite asset pipeline configuration controls every aspect of how those names are formed — but the defaults leave gaps that only appear in production under real CDN conditions.

This page walks through exactly which flags to set, what the manifest.json tells you, how Vite’s [hash] compares to Webpack’s [contenthash], and how to verify a build is truly deterministic before promoting it.

What Goes Wrong: Symptoms and Diagnosis

Stale-asset bugs share a short list of observable symptoms. Match yours before changing configuration.

The build produces filenames without any hash at all. This happens when rollupOptions.output is omitted and Vite falls back to library mode defaults. Run ls dist/assets/ after a production build — if you see index.js rather than index-BqDRllKq.js, hashing is off entirely.

Two consecutive builds of identical source produce different filenames. Non-deterministic output poisons CDN caches on every deploy even when nothing changed. The deterministic build outputs requirement is a prerequisite for safe long-lived caching. Reproduce this with:

npx vite build --mode production
find dist -type f \( -name '*.js' -o -name '*.css' \) | sort > build1.txt
rm -rf dist
npx vite build --mode production
find dist -type f \( -name '*.js' -o -name '*.css' \) | sort > build2.txt
diff build1.txt build2.txt

Zero output from diff means deterministic. Any line differences expose a non-determinism bug.

Dynamic imports return 404 after deployment. Vite emits lazy chunks with their own hashed names. If chunkFileNames is not explicitly set, Rollup may produce names that do not match what the entry bundle hardcoded at build time — particularly when build caches from a prior run are partially invalidated.

CSS loads from an old hash after a JS-only change. This is the inverse: CSS changes should not rotate the JS hash, and JS changes should not rotate the CSS hash. If both rotate together every time, your naming pattern is tied to the wrong input.

How Vite’s [hash] Token Works

Vite delegates bundling to Rollup. The output filenames are controlled by three fields on rollupOptions.output:

  • entryFileNames — the JavaScript file that is the entry point (the one referenced by a <script> tag)
  • chunkFileNames — every code-split or dynamically imported chunk
  • assetFileNames — CSS, images, fonts, and any other non-JS static file

Each field accepts a template string. The [hash] token in that template is replaced at build time with a hash Rollup derives from the file’s content. Change one byte of the source and the hash changes; leave the source identical across two builds and the hash is identical.

The hash length defaults to eight hex characters when you write [hash] without a length qualifier. You can extend it with [hash:12] or [hash:16]. Eight characters gives 4 billion possible values — sufficient for most applications, but monorepos with hundreds of output chunks should use twelve or sixteen to stay clear of accidental collisions. See the cache key architecture section for a discussion of collision probability at scale.

How does this differ from Webpack’s [contenthash]?

The semantics are identical — both tokens hash the output file’s content — but the spelling differs. Webpack uses [contenthash] in its output.filename and output.chunkFilename fields. Vite (through Rollup) uses [hash]. If you are migrating a project from Webpack, a direct find-and-replace of [contenthash] with [hash] in your output templates is the correct move. The underlying guarantee — that the hash reflects actual file bytes — is the same. The content hashing vs semantic versioning comparison explains why content-derived hashes are preferable to version numbers for cache busting in either tool.

Default Config vs Explicit rollupOptions.output: Decision Matrix

Scenario Default config sufficient? Explicit rollupOptions.output needed?
Single-page app, one entry point, no lazy routes Mostly — but CSS hash not guaranteed to be independent Yes, to isolate CSS hash rotation from JS changes
Multiple entry points (MPA) No — entry names collide without explicit templates Yes
Code-split routes (dynamic import()) Partial — chunk names are hashed, but pattern is non-configurable Yes, to control directory layout and hash length
Monorepo with shared packages No — default 8-char hash risks collision at scale Yes, use [hash:16]
Library build (build.lib) Different rules — entry names are semver by convention Not applicable to app builds
Assets inlined via assetsInlineLimit Irrelevant — inlined assets become data URIs, not files N/A (see “When to Reconsider” below)

The short answer: for any production application with a CDN in front of it, explicit output templates are not optional.

Pipeline Flow: From Source File to Hashed Asset on CDN

Vite content hashing pipeline A flow diagram showing source files entering Vite, Rollup computing content hashes, output files written to dist/assets with hashed names, the manifest.json index written to dist/.vite, the server reading the manifest to resolve URLs, and the CDN caching assets with immutable headers for one year. Source files main.ts app.css logo.svg Rollup (via Vite) entryFileNames chunkFileNames assetFileNames [hash:8] ← content dist/assets/ main-BqDRllKq.js app-Cx9mKpTz.css logo-Fz1aQrNv.svg + manifest.json CDN Edge Cache-Control: immutable max-age=31536000 ✓ 1 year TTL dist/.vite/manifest.json logical → hashed path read by SSR / deploy build emit deploy

Full vite.config.ts with Explicit Content Hashing

The following configuration is complete and production-ready. It enforces content-based filenames for all output types, enables the manifest, and hides source maps from public URLs.

import { defineConfig } from 'vite';

export default defineConfig({
  build: {
    // Write dist/.vite/manifest.json mapping logical names to hashed paths.
    // Required for SSR frameworks and deploy scripts that resolve asset URLs.
    manifest: true,

    // 'hidden' writes .map files alongside assets but does NOT add the
    // sourceMappingURL comment to the output file, keeping maps off the
    // public CDN while still allowing error monitoring tools to upload them.
    sourcemap: 'hidden',

    rollupOptions: {
      output: {
        // The JavaScript entry point loaded by the <script> tag in index.html.
        // [hash] = 8 hex chars derived from file content.
        // Use [hash:12] or [hash:16] in monorepos with many chunks.
        entryFileNames: 'assets/js/[name]-[hash].js',

        // Code-split chunks produced by dynamic import() calls.
        // Must use the same hash length as entryFileNames to avoid
        // mismatches in the import graph written into the entry bundle.
        chunkFileNames: 'assets/js/[name]-[hash].js',

        // CSS, images, fonts, and other static files.
        // Using a function here allows routing by extension.
        assetFileNames: ({ name }) => {
          if (/\.css$/.test(name ?? '')) {
            return 'assets/css/[name]-[hash][extname]';
          }
          if (/\.(png|jpg|jpeg|gif|webp|avif|svg)$/.test(name ?? '')) {
            return 'assets/img/[name]-[hash][extname]';
          }
          if (/\.(woff2?|ttf|eot|otf)$/.test(name ?? '')) {
            return 'assets/fonts/[name]-[hash][extname]';
          }
          return 'assets/[name]-[hash][extname]';
        },
      },
    },
  },
});

Every output type — entry JS, lazy chunks, CSS, images, fonts — now carries an independent 8-character content hash. A change to app.css rotates the CSS filename and nothing else. A change to a lazy route rotates that chunk and updates the hash reference inside the entry bundle, but leaves unrelated routes untouched.

Reading manifest.json: Structure and a resolveAsset() Helper

When manifest: true is set, Vite writes dist/.vite/manifest.json after every build. The file is a flat JSON object. Each key is the logical source path relative to the project root; the value is an object describing the emitted file.

{
  "src/main.ts": {
    "file": "assets/js/main-BqDRllKq.js",
    "src": "src/main.ts",
    "isEntry": true,
    "css": ["assets/css/app-Cx9mKpTz.css"],
    "assets": ["assets/img/logo-Fz1aQrNv.svg"],
    "imports": ["assets/js/vendor-Dz8mQpXr.js"]
  },
  "src/pages/Dashboard.tsx": {
    "file": "assets/js/Dashboard-Hn3kWqYa.js",
    "src": "src/pages/Dashboard.tsx",
    "isDynamicEntry": true
  }
}

Key fields:

  • file — the hashed path relative to dist/. This is what the CDN or server must serve.
  • isEntry — present and true on the bundle’s main entry point.
  • isDynamicEntry — present and true on code-split chunks.
  • css — array of CSS files emitted alongside this entry. Your SSR template must inject these as <link> tags.
  • imports — statically imported chunks. These are candidates for <link rel="modulepreload"> injection.

In a Node.js SSR server or deploy script, read the manifest once at startup and look up paths on demand:

import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';

type ManifestEntry = {
  file: string;
  src?: string;
  isEntry?: boolean;
  isDynamicEntry?: boolean;
  css?: string[];
  assets?: string[];
  imports?: string[];
};

type Manifest = Record<string, ManifestEntry>;

const manifest: Manifest = JSON.parse(
  readFileSync(resolve('dist/.vite/manifest.json'), 'utf-8')
);

/**
 * Resolve a logical source path to the CDN-ready hashed URL.
 * Throws if the entry does not exist — better to fail at startup
 * than to serve a 404 in production.
 */
function resolveAsset(logicalPath: string, base = '/'): string {
  const entry = manifest[logicalPath];
  if (!entry) {
    throw new Error(
      `Asset not found in manifest: "${logicalPath}". ` +
      `Run vite build and ensure the file is a build input.`
    );
  }
  return `${base}${entry.file}`;
}

// Usage in an Express route or SSR template:
// const scriptSrc = resolveAsset('src/main.ts');
// const styleSrc = manifest['src/main.ts'].css?.[0];

Verification: Two Builds, One jq Command

After configuring explicit output templates, verify the build is deterministic and the manifest is stable. Run both builds in a clean state — no partial dist/ left over from a prior run.

npx vite build --mode production
cp dist/.vite/manifest.json /tmp/manifest_build1.json

rm -rf dist

npx vite build --mode production
diff /tmp/manifest_build1.json dist/.vite/manifest.json && echo "Deterministic" || echo "Non-deterministic — investigate Rollup plugin order"

Then confirm every emitted file is actually hashed with the jq one-liner:

jq -r '.[].file' dist/.vite/manifest.json

Every line of output should contain a hyphen followed by eight hex characters before the extension. If any line lacks that pattern, the corresponding output template is missing the [hash] token.

When to Reconsider: assetsInlineLimit and Data URIs

What is assetsInlineLimit and when does it suppress hashing?

Vite’s build.assetsInlineLimit (default 4096 bytes, i.e. 4 kB) converts any static asset below that threshold into a base64 data URI embedded directly in the JavaScript bundle. Data URIs are not files — they have no URL, no filename, and no hash. They are also not cached independently by the browser; they live inside the JS bundle that contains them.

This trade-off is intentional for tiny assets: one fewer HTTP round-trip outweighs the caching benefit. But it has side effects worth knowing:

  • A small SVG icon that is inlined will not appear in manifest.json at all.
  • Changing that icon will change the hash of the JS bundle that imported it, even if no JS logic changed.
  • Setting assetsInlineLimit: 0 disables inlining entirely, giving every asset its own hashed file and manifest entry — the right choice when you want maximum cache isolation.

The decision point: if your performance budget prioritizes request count reduction (common in high-latency mobile contexts), keep the default threshold or raise it. If your priority is granular cache invalidation and independent asset rotation, set assetsInlineLimit: 0 and rely on HTTP/2 multiplexing to absorb the extra requests.

Should you use [hash:8] or a longer hash for CI pipelines?

Eight characters is the Vite/Rollup default and is safe for applications with fewer than ~100 output chunks. For monorepos that produce several hundred chunks in a single build, use [hash:12]. For CI pipelines where collision between artifacts across branches would cause silent cache poisoning, use [hash:16]. The cost is two to four extra URL characters — negligible. The benefit is eliminating a class of bugs that only appears at scale.

When does explicit rollup output configuration create more problems than it solves?

Library builds (build.lib mode) have different conventions: the entry filename is typically the package name at a semver, and the consuming application is responsible for its own fingerprinting. Applying [hash] to a library entry point breaks the expected package.json#main resolution. Keep explicit hash templates in application build configs only; for libraries, omit them and let the consumer’s bundler handle it.