Vite Asset Pipeline Configuration

Configuring Vite’s asset pipeline without explicit output naming rules produces unpredictable filenames, broken CDN caching, and deployment regressions — especially as projects scale to monorepos or hundreds of dynamic chunks.

Vite 5 ships with sensible defaults for development, but production requires deliberate choices about hash length, manifest emission, CDN base URLs, and the inline threshold below which assets are embedded rather than fingerprinted. This page covers every knob in depth and shows you exactly how to wire them together into a deterministic, CDN-ready pipeline. For a conceptual introduction to why content hashing matters more than version strings, start with Content Hashing vs Semantic Versioning.

When to Use This Configuration

Situation Recommended action
Single-page app deployed behind a CDN Full config: assetFileNames, chunkFileNames, entryFileNames, manifest: true, base set to CDN URL
SSR app (e.g. Express + Vite) Same as above; additionally parse dist/.vite/manifest.json at server startup for template injection
Monorepo with 200+ chunks Raise hash length to [hash:12] or [hash:16]; pin manualChunks to avoid phantom invalidations
Library package published to npm Omit manifest; set assetFileNames but leave base as '/' — consumers set their own base
Small app where every kilobyte matters Tune assetsInlineLimit upward to embed small images as base64; accept loss of individual file caching
Migrating from Webpack Map [contenthash:8] (Webpack) to [hash:8] (Rollup); see Webpack Output Hashing Setup for parity

Prerequisites

Requirement Version / flag
Node.js 18.0.0 or later (LTS recommended: 22.x)
Vite 5.0.0 or later (npm install vite@^5)
TypeScript (optional) 5.0+ for vite.config.ts; "moduleResolution": "bundler" in tsconfig.json
Rollup (bundled with Vite) 4.x (shipped with Vite 5); do not pin a separate rollup version
jq (for verification) 1.6 or later; install via apt install jq or brew install jq
@vitejs/plugin-react or similar Any version compatible with Vite 5

Run npx vite --version to confirm you are on 5.x before proceeding.

Configuration Reference

build.* options

Key Type Default Effect
build.manifest boolean | string false true emits dist/.vite/manifest.json. Pass a string to rename the file. Required for SSR and manifest-driven deployments.
build.assetsDir string 'assets' Subdirectory inside outDir where fingerprinted assets land. Set to 'static/assets' to avoid conflicts with framework routing.
build.outDir string 'dist' Root output directory. Clean it between builds with build.emptyOutDir: true.
build.assetsInlineLimit number 4096 (4 KB) Assets smaller than this byte threshold are inlined as base64 data URIs instead of emitted as files. Inlined assets are not fingerprinted and do not appear in the manifest.
build.base string '/' Prepended to every asset URL in emitted HTML and the manifest. Set to a CDN origin ('https://cdn.example.com/') for multi-origin deployments. Must end with /.
build.sourcemap boolean | 'inline' | 'hidden' false 'hidden' emits .map files without //# sourceMappingURL in the bundle — preferred for production debugging without leaking source paths.
build.minify boolean | 'esbuild' | 'terser' 'esbuild' 'esbuild' is fast and the default. 'terser' produces marginally smaller output at the cost of build time.
build.emptyOutDir boolean true when outDir is inside root Wipe outDir before each build. Always set explicitly to true to avoid stale fingerprinted files accumulating.

rollupOptions.output.* naming tokens

Key Type Default Effect
assetFileNames string | function 'assets/[name]-[hash][extname]' Pattern for non-JS assets (CSS, images, fonts, SVG). The [hash] token is a Rollup content hash derived from the file’s content.
chunkFileNames string | function 'assets/[name]-[hash].js' Pattern for code-split dynamic chunks.
entryFileNames string | function 'assets/[name]-[hash].js' Pattern for entry point bundles.
[hash:N] syntax Full hash length Truncates the Rollup content hash to N hex characters. Use [hash:8] for most projects. Use [hash:12] or [hash:16] in monorepos with thousands of chunks.
manualChunks object | function undefined Explicit chunk splitting rules. Pin shared dependencies here to prevent phantom hash invalidations when import order changes.

How the [hash] Token Works in Rollup (vs Webpack)

Rollup’s [hash] token is a content hash computed over the transformed, bundled output of a chunk or asset — not the raw source file. This means:

  • If you change a comment in utils.ts but the compiled output is identical, the hash stays the same.
  • If a dynamic import is added to main.ts, any chunk whose dependency graph changes will get a new hash — even if that chunk’s own code did not change.

This is subtly different from Webpack’s [contenthash], which is computed per-module before tree-shaking and merging. The practical consequence is that Rollup hashes are more sensitive to graph topology changes (import order, chunk splitting boundaries) and less sensitive to source-level whitespace. For a thorough comparison of deterministic build strategies, see Deterministic Build Outputs.

Hash length. Eight hex characters gives 4 billion possible values — sufficient for apps with hundreds of assets. In monorepos generating thousands of chunks simultaneously, the birthday-paradox collision risk becomes meaningful. Use [hash:12] (281 trillion values) or [hash:16] (18 quintillion values) for those environments. See How to Configure Content Hashing in Vite Production Builds for hash length analysis.

Step-by-Step Implementation

Step 1: Install and verify Vite 5

npm install vite@^5 @vitejs/plugin-react@^4
npx vite --version
# Expected output: vite/5.x.x

Step 2: Create a fully configured vite.config.ts

import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],

  build: {
    // Emit dist/.vite/manifest.json mapping source paths → hashed output paths.
    // SSR servers and CI scripts parse this file to resolve asset URLs at runtime.
    manifest: true,

    // Output directory. Always wipe between builds to avoid stale fingerprinted files.
    outDir: 'dist',
    emptyOutDir: true,

    // Fingerprinted assets land here: dist/static/assets/
    // Using a subdirectory avoids conflicts with framework routing rules.
    assetsDir: 'static/assets',

    // Base URL prepended to all asset references in emitted HTML and the manifest.
    // For CDN hosting, set this to your CDN origin.
    // Must end with '/'. Use '/' for same-origin deployments.
    base: process.env.CDN_BASE_URL ?? '/',

    // Assets smaller than this threshold (bytes) are inlined as base64 data URIs
    // and do NOT get fingerprinted filenames. Default is 4096 (4 KB).
    // Set to 0 to disable inlining entirely and fingerprint every asset.
    assetsInlineLimit: 4096,

    // Hidden sourcemaps: .map files exist on disk but are not referenced in bundles.
    // Useful for post-deploy debugging without exposing source paths to end users.
    sourcemap: 'hidden',

    rollupOptions: {
      output: {
        // Fingerprinted filenames for static assets (CSS, images, fonts, SVG, etc.)
        // [name]     = original filename without extension
        // [hash:8]   = first 8 hex chars of Rollup content hash
        // [extname]  = original extension including the dot
        assetFileNames: 'static/assets/[name]-[hash:8][extname]',

        // Fingerprinted filenames for dynamically imported code chunks.
        chunkFileNames: 'static/assets/chunks/[name]-[hash:8].js',

        // Fingerprinted filenames for entry point bundles.
        entryFileNames: 'static/assets/entry/[name]-[hash:8].js',

        // Pin third-party dependencies into a stable 'vendor' chunk.
        // Without this, changing any import in a vendor-adjacent file
        // can cascade hash changes across unrelated chunks.
        manualChunks(id) {
          if (id.includes('node_modules')) {
            return 'vendor';
          }
        },
      },
    },
  },
});

Step 3: Build and inspect output

# Build for production
npx vite build

# List fingerprinted JS files
find dist/static/assets -type f -name "*.js"
# Example output:
# dist/static/assets/entry/main-a1b2c3d4.js
# dist/static/assets/chunks/vendor-e5f6a7b8.js
# dist/static/assets/chunks/About-c9d0e1f2.js

# List fingerprinted CSS
find dist/static/assets -type f -name "*.css"
# Example output:
# dist/static/assets/index-3a4b5c6d.css

Step 4: Inspect dist/.vite/manifest.json

# Pretty-print the manifest
jq '.' dist/.vite/manifest.json

A typical manifest entry looks like this:

{
  "src/main.tsx": {
    "file": "static/assets/entry/main-a1b2c3d4.js",
    "name": "main",
    "src": "src/main.tsx",
    "isEntry": true,
    "imports": [
      "static/assets/chunks/vendor-e5f6a7b8.js"
    ],
    "css": [
      "static/assets/index-3a4b5c6d.css"
    ]
  },
  "src/pages/About.tsx": {
    "file": "static/assets/chunks/About-c9d0e1f2.js",
    "name": "About",
    "src": "src/pages/About.tsx",
    "isDynamicEntry": true
  }
}

Each key is the source path relative to the project root. The file value is the hashed output path relative to outDir. The css array lists stylesheets generated by this entry. The imports array lists synchronous chunk dependencies.

Step 5: Parse the manifest in a Node.js server

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

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

type Manifest = Record<string, ManifestEntry>;

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

export function resolveAsset(sourcePath: string): ManifestEntry {
  const entry = manifest[sourcePath];
  if (!entry) {
    throw new Error(
      `Asset "${sourcePath}" not found in manifest. ` +
      `Run "vite build" and check dist/.vite/manifest.json.`
    );
  }
  return entry;
}

// Usage in an Express template handler:
// const { file, css } = resolveAsset('src/main.tsx');
// Inject `/${file}` as the script src and `/${css[0]}` as the stylesheet href.

Step 6: Configure CDN cache headers

Once fingerprinted assets are on your origin, instruct the CDN to cache them indefinitely. The filename changes on every content change, so the immutable directive is safe. For a deep dive on cache rule configuration, see Cloudflare Cache Rules and Purge.

Nginx:

server {
    listen 443 ssl;
    server_name example.com;

    root /var/www/dist;

    # Fingerprinted assets: cache forever, serve as immutable.
    location /static/assets/ {
        expires 1y;
        add_header Cache-Control "public, max-age=31536000, immutable";
        add_header X-Content-Type-Options nosniff;
        try_files $uri =404;
    }

    # HTML entry points: must not be cached long-term.
    location / {
        add_header Cache-Control "no-cache, must-revalidate";
        try_files $uri /index.html;
    }
}

Express (Node.js):

import express from 'express';
import { resolve } from 'path';

const app = express();
const distPath = resolve(__dirname, '../dist');

// Fingerprinted assets: long-lived immutable cache.
app.use(
  '/static/assets',
  express.static(resolve(distPath, 'static/assets'), {
    maxAge: '1y',
    immutable: true,
  })
);

// HTML and other files: no long-term caching.
app.use(express.static(distPath, { maxAge: 0 }));

SVG Diagram: Vite Asset Pipeline Data Flow

Vite Asset Pipeline Data Flow Diagram showing how source files are processed by Vite and Rollup, emitting fingerprinted assets and a manifest.json, which then flow to origin server and CDN for browser delivery. Vite 5 Asset Pipeline: Build → Manifest → CDN Source Files src/main.tsx src/pages/About.tsx src/styles/app.css public/logo.svg vite build Vite 5 + Rollup 4 assetFileNames chunkFileNames entryFileNames [hash:8] token dist/static/assets/ entry/main-a1b2c3d4.js chunks/vendor-e5f6a7b8.js index-3a4b5c6d.css ✓ Fingerprinted dist/.vite/manifest.json "src/main.tsx": file: entry/main-a1b2… css: [index-3a4b…] build.manifest: true parse at startup Origin Server resolveAsset() injects hashed URLs into HTML response upload CDN max-age immutable Browser requests /static/assets/entry/main-a1b2c3d4.js served from CDN edge assetsInlineLimit Files < 4 KB → base64 data URI, not fingerprinted

Verification Shell Commands

Run these after every build to catch regressions before deployment.

# 1. Confirm the manifest was emitted
test -f dist/.vite/manifest.json && echo "PASS: manifest exists" || echo "FAIL: manifest missing"

# 2. Confirm all JS entry points are fingerprinted (8-char hex hash before .js)
find dist/static/assets/entry -name "*.js" | grep -E '\-[0-9a-f]{8}\.js$' \
  && echo "PASS: entry hashes look correct" || echo "FAIL: entry hash pattern mismatch"

# 3. Confirm no un-hashed JS files leaked into the assets directory
UNHASHED=$(find dist/static/assets -name "*.js" | grep -v -E '\-[0-9a-f]{8}\.js$')
if [ -z "$UNHASHED" ]; then echo "PASS: no un-hashed JS files"; else echo "FAIL: $UNHASHED"; fi

# 4. Verify determinism: build twice, compare manifests
npx vite build --outDir dist_run1
npx vite build --outDir dist_run2
diff <(jq -S '.' dist_run1/.vite/manifest.json) <(jq -S '.' dist_run2/.vite/manifest.json) \
  && echo "PASS: build is deterministic" || echo "FAIL: non-deterministic output"

# 5. Confirm all manifest "file" values exist on disk
jq -r '.[].file' dist/.vite/manifest.json | while read -r f; do
  test -f "dist/$f" && echo "PASS: $f" || echo "FAIL: missing $f"
done

# 6. Confirm no files under assetsInlineLimit appeared in the manifest
# (Files that were inlined are absent from the manifest — this checks for over-inlining)
MANIFEST_COUNT=$(jq 'length' dist/.vite/manifest.json)
echo "INFO: manifest contains $MANIFEST_COUNT entries"

# 7. Check all CSS in the manifest is also present on disk
jq -r '.[].css[]? ' dist/.vite/manifest.json | while read -r css; do
  test -f "dist/$css" && echo "PASS: $css" || echo "FAIL: missing CSS $css"
done

Edge Cases and Known Issues

Monorepo phantom hash changes

In a monorepo where multiple packages import a shared utility, Rollup recomputes chunk hashes when the import graph order changes — even if no code changed. This causes false invalidations: the CDN evicts assets that are byte-for-byte identical.

Fix: Pin shared dependencies explicitly in manualChunks. Give each shared package a named chunk:

rollupOptions: {
  output: {
    manualChunks(id) {
      if (id.includes('packages/ui/src')) return 'ui-lib';
      if (id.includes('packages/utils/src')) return 'utils-lib';
      if (id.includes('node_modules')) return 'vendor';
    },
    chunkFileNames: 'static/assets/chunks/[name]-[hash:12].js',
  },
},

Also raise the hash length to [hash:12] in monorepos to lower collision probability as chunk count grows. For more on why build determinism matters for cache efficiency, see Deterministic Build Outputs.

The assetsInlineLimit fingerprinting gap

Any file under assetsInlineLimit bytes is converted to a base64 data URI and embedded directly in the JavaScript bundle. It does not appear as a file in dist/, and it does not appear in dist/.vite/manifest.json.

Consequences:

  • You cannot cache-bust it independently; it rides along with the JS bundle hash.
  • If you reference the asset path in server-side code expecting a manifest entry, resolveAsset() will throw.
  • SVG icons and small PNGs are common culprits.

Fix options:

  • Set assetsInlineLimit: 0 to force all assets to disk as fingerprinted files.
  • Use the ?url import suffix (import logoUrl from './logo.svg?url') to force URL emission regardless of size.
// Force this specific asset to emit a fingerprinted file, skip inlining:
import logoUrl from './logo.svg?url';

base trailing slash

Omitting the trailing slash from base causes malformed URLs. Vite does not add it automatically.

// Wrong — produces URLs like '/cdn/static/assets/main-abc12345.js' with double slashes
base: '/cdn'

// Correct
base: '/cdn/'

// Also correct for CDN origins
base: 'https://cdn.example.com/'

build.manifest type changed in Vite 5

In Vite 4, build.manifest accepted only boolean. In Vite 5, it also accepts a string to rename the output file. If you are upgrading from Vite 4, verify your TypeScript types are updated:

// Vite 4 and 5: boolean works in both
manifest: true

// Vite 5 only: emit as 'asset-manifest.json' instead of 'manifest.json'
manifest: 'asset-manifest.json'

Rollup plugin ordering and hash instability

Third-party Rollup plugins that run in the generateBundle or renderChunk hooks and mutate chunk content after hashing will cause the emitted hash to diverge from the content. Always test plugin interactions with the determinism check above (verification step 4). For esbuild-specific plugin patterns, see esbuild Fingerprinting Plugins.

Performance Impact

assetsInlineLimit tradeoffs

Scenario Inline (data URI) Separate file
HTTP/2 push available Neutral Parallel fetch, lower JS parse cost
First paint, cold cache Faster (no extra round trip) Slower (extra request)
Subsequent pages (warm cache) Always re-parsed in JS bundle Served from browser cache in milliseconds
Asset changes frequently Forces full JS re-download Only that file re-downloaded
Asset is reused across many pages Duplicated in every bundle Fetched once, cached across pages

The default 4 KB threshold is a reasonable balance for icons and small decorative assets. For assets shared across multiple entry points (a site-wide logo, a global CSS reset), prefer separate files with long cache headers.

Build time

Increasing hash length (from [hash:8] to [hash:16]) has no measurable effect on build time. The hash computation is O(file size), not O(hash length). The dominant build-time factor in large projects is the number of modules and the depth of the import graph.

Enabling build.sourcemap: 'hidden' adds roughly 10–30% to build time depending on bundle size, because source maps are generated in parallel with minification.

Cache hit ratios

Fine-grained manualChunks splits that keep the vendor chunk stable across deploys routinely achieve 80–95% CDN cache hit ratios for returning users. Without pinning, a single package.json dependency version bump can cascade to invalidate every chunk. The Cache Key Architecture page covers how to measure and target hit ratio improvements using CDN analytics.

Frequently Asked Questions

What is the difference between Rollup’s [hash] and Webpack’s [contenthash]?

Both tokens are content-derived hex strings, but they are computed at different stages of the pipeline. Webpack’s [contenthash] is computed per-module from the source after transformation but before bundling. Rollup’s [hash] is computed from the final bundled chunk output after tree-shaking, concatenation, and minification. In practice this means Rollup hashes are more stable against source-level changes that compile away (comments, type annotations) but more sensitive to graph topology changes (import order, new dynamic imports). For a side-by-side comparison of these two approaches, see Webpack Output Hashing Setup.

Why are some of my images missing from dist/.vite/manifest.json?

Images below assetsInlineLimit (default 4096 bytes) are inlined as base64 data URIs inside the JavaScript bundle. They are not emitted as separate files, so they do not appear in the manifest. To force them to disk, either set assetsInlineLimit: 0 globally or append ?url to the specific import: import imgUrl from './icon.png?url'. The ?url suffix tells Vite to always emit the file and return its hashed URL, regardless of size.

How do I handle the manifest in a static site generator that pre-renders HTML at build time?

Most static generators (Astro, SvelteKit in static mode, Nuxt generate) run their own Vite build and consume the manifest internally — you do not parse it yourself. If you are running Vite independently and feeding the output to a separate renderer, parse the manifest during the renderer’s build step, not at runtime. Pass the resolved hashed paths as template variables. See Rollup Asset Optimization for patterns applicable to static output pipelines.

What happens if I need to roll back a deploy after bad assets were pushed to the CDN?

Because fingerprinted filenames are immutable, rolling back means redeploying the previous build artifacts (the old hashed filenames) and updating the HTML entry point to reference them again. The old files may still be on the CDN if they have not expired. If they were purged, you will need to re-upload them from your build artifacts. A full rollback strategy, including how to re-serve a previous manifest, is covered in Rolling Back a Vite Asset Hash After a Bad Deploy.