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 critical detail is which bytes. The hash is not taken over your source file. It is taken over the bytes Rollup is about to write to disk, at the very end of the chunk’s journey through the build.

What the chunk hash is computed over A vertical stack of build stages: TypeScript or JSX source, transform and tree-shake, chunk concatenation, minification, and the final chunk bytes, with the hash taken over the last stage only and truncated to eight hex characters to form the filename. What the chunk hash is computed over TypeScript / JSX source comments and types still present Transform + tree-shake dead code removed Chunk concatenation import order fixes byte order Minify (esbuild) minifier version changes bytes Final chunk bytes hashed, then first 8 hex chars vendor-e5f6a7b8.js
Only the last stage feeds the hash — which is why comment edits are free and minifier upgrades are not.

Two consequences follow directly from that diagram. Deleting a comment costs nothing, because the comment never reaches the hashed bytes. Bumping esbuild by a patch release costs everything, because a different minifier emits different bytes for identical input, and every chunk therefore gets a new name. That is the single most common cause of a “nothing changed but every hash rotated” incident, and it is why lockfiles matter as much as source control here.

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.

Which Template Names Which Output

Getting the three templates confused produces the most confusing class of bug: hashing appears to work, but one category of file silently escapes it. Each template governs a disjoint set of emitted files, and every file Rollup writes falls under exactly one of them.

Template to output-type mapping entryFileNames names the script referenced by index.html, chunkFileNames names every dynamic import chunk plus manualChunks output, and assetFileNames names CSS, fonts, images and media. Which template names which output entryFileNames assets/js/[name]-[hash].js index.html script target one per rollupOptions.input key chunkFileNames assets/js/[name]-[hash].js every dynamic import chunk plus every manualChunks group assetFileNames assets/[name]-[hash][extname] CSS, fonts, images, media and anything imported as a URL
Three templates, three disjoint sets of files — omit one and that category ships unhashed.

Note that chunkFileNames covers both halves of code splitting: chunks Rollup creates on its own for a dynamic import(), and chunks you name yourself through manualChunks. A single template governs both, so a directory layout you pick for lazy routes automatically applies to your pinned vendor bundle too.

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.

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]';
        },

        // Keep third-party code in its own chunk so an app-only edit
        // cannot rotate the framework bundle every user has cached.
        manualChunks(id) {
          if (id.includes('node_modules')) {
            return 'vendor';
          }
        },
      },
    },
  },
});

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.

The difference is measurable on the very next deploy. Edit one declaration in a stylesheet and compare what the browser has to re-fetch under each configuration.

Effect of a CSS-only edit on emitted filenames With a single output template and no chunk pinning, all three emitted files get new hashes after a CSS-only edit. With per-type templates and a pinned vendor chunk, only the stylesheet changes name. Effect of a CSS-only edit on emitted filenames One template, no pinning app-3a4b5c6d.css becomes new main-a1b2c3d4.js becomes new vendor-e5f6a7b8.js becomes new 3 of 3 files re-downloaded Per-type templates + pinning app-71c9d0aa.css becomes new main-a1b2c3d4.js unchanged vendor-e5f6a7b8.js unchanged 1 of 3 files re-downloaded Only the file whose bytes actually changed should change its name
The whole point of per-type templates: an edit invalidates one file, not the entire payload.

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

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.

There is also a case where explicit output configuration is the wrong move. 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-field resolution. Keep explicit hash templates in application build configs only; for libraries, omit them and let the consumer’s bundler handle it.

Frequently Asked Questions

Should I use [hash:8] or a longer hash in CI pipelines?

Eight characters is the Vite and Rollup default and is safe for applications with fewer than roughly 100 output chunks. For monorepos that produce several hundred chunks in a single build, use [hash:12]. For CI pipelines where a collision between artifacts built on different branches would silently poison a shared cache, use [hash:16]. The cost is two to four extra URL characters. The benefit is eliminating a class of bug that only shows up at scale, and only intermittently.

Do entryFileNames and chunkFileNames have to use the same hash length?

Yes, in practice. The entry bundle contains the literal hashed URLs of the chunks it imports, written in at build time. Mixing lengths does not break the build, but it makes the emitted graph harder to audit and makes CDN path rules that match on hash length unreliable. Pick one length and apply it to all three templates.

Why did every hash change when I only bumped a dependency patch version?

Because the bytes changed. A patch release of a transitive dependency alters the code inside your vendor chunk, which changes that chunk’s hash, which changes the import URL written into your entry chunk, which changes the entry hash. The cascade is expected and correct. What is not expected is the same cascade with an unchanged lockfile — that indicates a non-deterministic toolchain, and the two-build diff above is the way to confirm it.

Can I hash the source instead of the output so the name survives a minifier upgrade?

No, and you should not want to. A hash that does not reflect the bytes on disk breaks the immutability promise: two different payloads could share a URL, and a cached copy would never be corrected. If minifier churn is the problem, pin the toolchain rather than weakening the hash.