Build Tool & Framework Asset Pipeline Integration

A complete reference for configuring Webpack 5, Vite 5, Rollup 4, esbuild 0.20+, Next.js 14, and Astro 4 to emit content-hashed filenames, generate deployment manifests, and wire into CI/CD pipelines for atomic CDN releases. Aimed at frontend engineers and release engineers who need exact configuration, not conceptual overviews.

Every bundler covered here embeds a cryptographic digest of each file’s contents into the output filename. When source bytes change, the hash changes, the URL changes, and CDN edges treat the new URL as a fresh resource — requiring no cache purge for the asset itself. This is the foundation of the immutable-cache pattern that lets you set Cache-Control: public, max-age=31536000, immutable on every fingerprinted file.

Why the Build Tool Layer Is the Critical Path

Cache invalidation strategies, CDN configuration, and deployment tooling all depend on the build tool doing its job correctly. If a bundler emits inconsistent hashes across two CI runners building the same commit, you get phantom hash changes that force unnecessary cache misses and complicate rollback. If it uses the wrong hash token — [hash] vs [contenthash] in Webpack — one changed module invalidates every chunk, shattering the cache.

The table below maps each major bundler to the hash token it uses in output templates, the algorithm, the default digest length, and whether it ships a manifest by default.

Bundler Hash token Algorithm Default length Manifest out of the box
Webpack 5 [contenthash] MD4 (configurable) 20 hex No — needs WebpackManifestPlugin
Vite 5 [hash] in rollupOptions.output SHA-256 (truncated) 8 hex No — use vite-plugin-manifest or read manifest.json with build.manifest: true
Rollup 4 [hash] SHA-256 (truncated) 8 hex No — manual plugin required
esbuild 0.20+ [hash] in assetNames/chunkNames SHA-256 (truncated) 8 hex Yes — metafile: true produces meta.json
Next.js 14 [contenthash] (via webpack internally) MD4 8 hex (via override) Yes — .next/build-manifest.json
Astro 4 [hash] (Rollup-based) SHA-256 (truncated) 8 hex Yes — dist/_astro/ with injected references

Use 8 hex digits (4 billion possible values) for projects with fewer than a few hundred chunks. For monorepos producing thousands of chunks in the same namespace, raise to 12–16 hex to reduce collision probability to negligible levels.

Bundler fingerprinting matrix A matrix diagram showing Webpack 5, Vite 5, Rollup 4, esbuild, Next.js, and Astro each emitting a content hash, feeding into a manifest, and then propagating to CDN edge nodes for immutable delivery. Bundler Hash token Manifest CDN edge Webpack 5 md4 · 20 hex default [contenthash:8] ManifestPlugin manual install immutable · 1y TTL Vite 5 sha-256 · 8 hex [hash:8] build.manifest: true opt-in immutable · 1y TTL Rollup 4 sha-256 · 8 hex [hash:8] plugin required generateBundle hook immutable · 1y TTL esbuild 0.20+ sha-256 · 8 hex [hash] metafile: true built-in immutable · 1y TTL Next.js 14 md4 · 8 hex [contenthash:8] via webpack config build-manifest.json built-in _next/static/** immutable · 1y TTL Astro 4 sha-256 · 8 hex [hash:8] injected into HTML automatic _astro/** immutable · 1y TTL
Bundler fingerprinting matrix: hash token, manifest mechanism, and CDN edge delivery pattern for each major build tool.

Webpack 5 Content Hashing

Webpack 5 ships with [contenthash] in its output template system. The distinction between [hash], [chunkhash], and [contenthash] is significant: [hash] is a build-wide hash that changes any time anything changes; [chunkhash] is per chunk but includes the chunk graph; [contenthash] is derived from the actual emitted bytes of each module, making it the only token suitable for long-term immutable caching. The detailed breakdown lives in the Webpack output hashing setup guide, including the specific failure modes documented in fixing missing asset hashes in Webpack 5.

// webpack.config.js
const { WebpackManifestPlugin } = require('webpack-manifest-plugin');
const path = require('path');

module.exports = {
  mode: 'production',
  entry: {
    main: './src/index.js',
    vendor: './src/vendor.js'
  },
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: 'assets/[name]-[contenthash:8].js',
    chunkFilename: 'assets/[name]-[contenthash:8].chunk.js',
    assetModuleFilename: 'assets/[name]-[contenthash:8][ext]',
    clean: true
  },
  optimization: {
    moduleIds: 'deterministic',
    chunkIds: 'deterministic',
    runtimeChunk: 'single'
  },
  plugins: [
    new WebpackManifestPlugin({
      fileName: 'asset-manifest.json',
      publicPath: process.env.CDN_BASE_URL || '/'
    })
  ]
};

Two optimization keys matter for determinism: moduleIds: 'deterministic' and chunkIds: 'deterministic'. Without them, Webpack assigns numeric IDs in discovery order, so adding a new module changes IDs — and therefore [contenthash] — of unrelated chunks. The runtimeChunk: 'single' extraction prevents the runtime loader from dirtying every chunk when any entry point changes.

The 8-character suffix yields 4 billion possible values — sufficient for projects with up to a few hundred simultaneous chunks. Raise to [contenthash:12] in monorepos where many teams produce thousands of chunks to a shared namespace.

Vite 5 Content Hashing

Vite wraps Rollup for production builds. Its [hash] token in rollupOptions.output is a SHA-256 digest of the module’s final emitted bytes, truncated to 8 hex characters by default. Enabling build.manifest writes a dist/.vite/manifest.json that maps every logical input path to its hashed output URL — this file is essential for server-side template rendering. The full configuration reference is in the Vite asset pipeline configuration guide, with the step-by-step production setup at how to configure content hashing in Vite production builds.

// vite.config.js
import { defineConfig } from 'vite';

export default defineConfig({
  build: {
    manifest: true,
    rollupOptions: {
      input: {
        main: 'src/main.js',
        admin: 'src/admin.js'
      },
      output: {
        entryFileNames: 'assets/[name]-[hash:8].js',
        chunkFileNames: 'assets/[name]-[hash:8].js',
        assetFileNames: 'assets/[name]-[hash:8][extname]'
      }
    },
    assetsDir: 'assets',
    assetsInlineLimit: 0
  }
});

Setting assetsInlineLimit: 0 prevents small assets (below 4 KB by default) from being inlined as data URIs, which would strip the filename hash. Every file — even small SVG icons — gets an explicit fingerprinted URL, enabling individual cache control.

Rollup 4 Manifest Generation

Rollup’s [hash] token operates at the chunk level. No built-in manifest exists, so a generateBundle hook is needed to emit one. The Rollup asset optimization guide covers tree-shaking and chunk strategy; the manifest-specific walkthrough is at generating a Rollup asset manifest for CDN deploys.

// rollup.config.js
import { createHash } from 'crypto';

function manifestPlugin() {
  return {
    name: 'manifest',
    generateBundle(options, bundle) {
      const manifest = {};
      for (const [fileName, chunk] of Object.entries(bundle)) {
        if (chunk.type === 'chunk' && chunk.facadeModuleId) {
          const inputKey = chunk.facadeModuleId.replace(process.cwd() + '/', '');
          manifest[inputKey] = options.dir + '/' + fileName;
        } else if (chunk.type === 'asset') {
          manifest[chunk.name] = options.dir + '/' + fileName;
        }
      }
      this.emitFile({
        type: 'asset',
        fileName: 'asset-manifest.json',
        source: JSON.stringify(manifest, null, 2)
      });
    }
  };
}

export default {
  input: { main: 'src/index.js', worker: 'src/worker.js' },
  output: {
    dir: 'dist',
    format: 'es',
    entryFileNames: 'assets/[name]-[hash:8].js',
    chunkFileNames: 'assets/[name]-[hash:8].js',
    assetFileNames: 'assets/[name]-[hash:8][extname]'
  },
  plugins: [manifestPlugin()]
};

esbuild 0.20+ Fingerprinting

esbuild’s assetNames and chunkNames options accept a [hash] placeholder that uses an 8-character SHA-256 truncation. The metafile: true flag produces a meta.json describing every input→output mapping. This file serves as the source-of-truth manifest in downstream CI steps. See integrating esbuild with CDN fingerprinting workflows for the CDN upload pattern.

// build.mjs
import * as esbuild from 'esbuild';
import { writeFileSync } from 'fs';

const result = await esbuild.build({
  entryPoints: {
    main: 'src/index.js',
    styles: 'src/styles.css'
  },
  bundle: true,
  minify: true,
  splitting: true,
  format: 'esm',
  outdir: 'dist/assets',
  assetNames: '[name]-[hash]',
  chunkNames: '[name]-[hash]',
  entryNames: '[name]-[hash]',
  metafile: true,
  define: {
    'process.env.NODE_ENV': '"production"'
  }
});

writeFileSync('dist/meta.json', JSON.stringify(result.metafile, null, 2));

// Derive a deployment manifest from the metafile
const manifest = {};
for (const [outPath, meta] of Object.entries(result.metafile.outputs)) {
  if (meta.entryPoint) {
    manifest[meta.entryPoint] = '/' + outPath;
  }
}
writeFileSync('dist/asset-manifest.json', JSON.stringify(manifest, null, 2));

esbuild does not support code splitting in CommonJS (cjs) format; use format: 'esm' with splitting: true for dynamic imports to get individual hashed chunks rather than one monolithic bundle.

Next.js 14 Static Asset Handling

Next.js manages fingerprinting internally through its Webpack configuration. The _next/static/ path prefix receives immutable cache headers automatically in most deployment targets. When hosting on a custom CDN, override the assetPrefix to point at your origin, then apply Cache-Control: public, max-age=31536000, immutable at the CDN layer for everything under /_next/static/. The full configuration is in the Next.js static asset handling guide, with the public/ vs _next/static/ distinction explained at Next.js asset folder vs public directory hashing.

// next.config.js
/** @type {import('next').NextConfig} */
module.exports = {
  assetPrefix: process.env.CDN_BASE_URL || '',
  compress: false,
  webpack: (config, { isServer }) => {
    if (!isServer) {
      config.output.filename = 'static/chunks/[name]-[contenthash:8].js';
      config.output.chunkFilename = 'static/chunks/[name]-[contenthash:8].chunk.js';
    }
    config.optimization.moduleIds = 'deterministic';
    return config;
  },
  async headers() {
    return [
      {
        source: '/_next/static/:path*',
        headers: [
          {
            key: 'Cache-Control',
            value: 'public, max-age=31536000, immutable'
          }
        ]
      }
    ];
  }
};

The public/ directory in Next.js is served at the root without hashing. Files in public/ must be managed with Cache-Control: no-cache or short TTLs, or renamed manually when their content changes. The build manifest at .next/build-manifest.json maps each page to its fingerprinted chunk dependencies — read it in your deployment scripts to verify that expected hashes appeared.

Astro 4 Build-Time Hashing

Astro uses Rollup under the hood for its production build, but abstracts the output template entirely. All script and style assets are emitted to dist/_astro/ with 8-character hashes appended, and HTML files reference them directly with the correct hashed paths. There is no explicit configuration required for hashing — enabling the production build via astro build is sufficient. For advanced scenarios — custom output paths, CDN URL rewriting, or SRI hash injection — see the Astro build-time hashing guide and the Astro static asset optimization and fingerprinting reference.

// astro.config.mjs
import { defineConfig } from 'astro/config';

export default defineConfig({
  build: {
    assets: '_astro',
    inlineStylesheets: 'never'
  },
  vite: {
    build: {
      rollupOptions: {
        output: {
          assetFileNames: '_astro/[name]-[hash:8][extname]',
          chunkFileNames: '_astro/[name]-[hash:8].js',
          entryFileNames: '_astro/[name]-[hash:8].js'
        }
      },
      assetsInlineLimit: 0
    }
  }
});

Setting inlineStylesheets: 'never' prevents Astro from inlining critical CSS into <style> tags. Inlined styles bypass fingerprinting entirely; keeping them in external files preserves the hash-on-change guarantee.

Manifest Generation and Format

Every build tool should produce a JSON manifest mapping logical asset names to their hashed URLs. This manifest is consumed in three places: server-side template rendering (inject the correct hashed URL into <script src=""> and <link href="">), deployment scripts (verify the expected files exist before switching traffic), and subresource integrity validation (compute and embed the integrity attribute).

A normalized manifest format used across tools looks like:

{
  "src/index.js": "/assets/main-a1b2c3d4.js",
  "src/styles.css": "/assets/styles-e5f6a7b8.css",
  "src/logo.svg": "/assets/logo-c9d0e1f2.svg"
}

The keys are source-relative paths; the values are deployment-root-relative hashed URLs. When assetPrefix or a CDN origin is involved, store both the relative path (for local server resolution) and the absolute CDN URL as separate fields.

For SRI, extend the manifest to include the integrity value:

{
  "src/index.js": {
    "url": "/assets/main-a1b2c3d4.js",
    "integrity": "sha256-4REjAZCbTQhPtNuMrCGxStXoGJLl5OZJ8M2h3p6SWI="
  }
}

Compute SRI hashes at build time with:

openssl dgst -sha256 -binary dist/assets/main-a1b2c3d4.js \
  | openssl base64 -A \
  | awk '{print "sha256-" $0}'

CI/CD Integration

A robust CI/CD pipeline for fingerprinted assets has five sequential stages: build, verify hashes, upload assets, deploy HTML, invalidate HTML cache. The detail on this pattern is in the CI/CD asset pipeline integration guide. Here is a complete GitHub Actions workflow implementing atomic deployment to S3 and Cloudflare:

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

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

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

      - name: Install dependencies
        run: npm ci

      - name: Build
        run: npm run build
        env:
          NODE_ENV: production
          CDN_BASE_URL: ${{ vars.CDN_BASE_URL }}

      - name: Verify manifest exists
        run: |
          test -f dist/asset-manifest.json || (echo "Manifest missing" && exit 1)
          node -e "
            const m = JSON.parse(require('fs').readFileSync('dist/asset-manifest.json', 'utf8'));
            const keys = Object.keys(m);
            if (keys.length === 0) { console.error('Empty manifest'); process.exit(1); }
            console.log('Manifest ok:', keys.length, 'entries');
          "

      - name: Verify hash format
        run: |
          node -e "
            const m = JSON.parse(require('fs').readFileSync('dist/asset-manifest.json', 'utf8'));
            for (const [src, url] of Object.entries(m)) {
              const urlStr = typeof url === 'string' ? url : url.url;
              if (!/[a-f0-9]{8}/.test(urlStr)) {
                console.error('Hash missing in:', urlStr);
                process.exit(1);
              }
            }
            console.log('All hashes present');
          "

      - name: Upload fingerprinted assets to S3
        run: |
          aws s3 sync dist/assets/ s3://${{ vars.S3_BUCKET }}/assets/ \
            --cache-control "public, max-age=31536000, immutable" \
            --metadata-directive REPLACE \
            --no-progress
        env:
          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          AWS_DEFAULT_REGION: us-east-1

      - name: Upload HTML entry points to S3
        run: |
          aws s3 sync dist/ s3://${{ vars.S3_BUCKET }}/ \
            --exclude "assets/*" \
            --cache-control "no-cache, must-revalidate" \
            --metadata-directive REPLACE \
            --no-progress
        env:
          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          AWS_DEFAULT_REGION: us-east-1

      - name: Purge HTML cache on Cloudflare
        run: |
          curl -s -X POST \
            "https://api.cloudflare.com/client/v4/zones/${{ secrets.CF_ZONE_ID }}/purge_cache" \
            -H "Authorization: Bearer ${{ secrets.CF_API_TOKEN }}" \
            -H "Content-Type: application/json" \
            --data '{"purge_everything":false,"files":["${{ vars.SITE_URL }}/","${{ vars.SITE_URL }}/index.html"]}' \
            | tee /tmp/cf_purge.json
          node -e "
            const r = JSON.parse(require('fs').readFileSync('/tmp/cf_purge.json', 'utf8'));
            if (!r.success) { console.error('Purge failed', JSON.stringify(r.errors)); process.exit(1); }
            console.log('Purge ok');
          "

The sequence — assets first, HTML second, HTML purge third — is the atomic deployment contract. Assets are immutable once on the CDN; the HTML document is the only pointer that needs a cache transition. Only the HTML cache requires purging; the CDN purge strategies reference covers Cloudflare, Fastly, CloudFront, and Nginx scenarios in detail.

CDN Propagation and Edge Behavior

After deployment, fingerprinted assets propagate to CDN edge nodes on first request from each region. With Cache-Control: public, max-age=31536000, immutable, edges cache the file for one year and skip revalidation entirely. The cache key architecture reference explains why filename-embedded hashes — rather than query parameters — achieve reliable edge caching. Most CDNs normalize query strings or ignore them entirely for cache keys; a unique filename is the only portable mechanism.

For Cloudflare, set a Cache Rule that applies to /_next/static/* or /assets/* patterns:

Cache Level: Cache Everything
Edge Cache TTL: 1 year (31536000 seconds)
Browser Cache TTL: Respect Existing Headers

For Nginx acting as an origin cache or reverse proxy:

location ~* ^/assets/.*\.[a-f0-9]{8}\.(js|css|svg|woff2|png|webp)$ {
    expires 1y;
    add_header Cache-Control "public, max-age=31536000, immutable";
    add_header Vary "Accept-Encoding";
    gzip_static on;
}

The regex [a-f0-9]{8} in the location block ensures that only fingerprinted paths receive the immutable header, preventing accidentally long-cached unversioned files.

For AWS CloudFront, apply a Cache Policy with Max TTL: 31536000 and Default TTL: 86400 to the /assets/* behavior, with origin headers forwarded. Unversioned paths (/index.html) need a separate behavior with Cache-Control: no-cache forwarded from the origin.

Verification Workflow

After deploying, confirm the pipeline produced valid hashes and the CDN is serving them correctly.

Step 1: Local build verification

# Confirm hashes are present in built filenames
ls -1 dist/assets/ | grep -E '[a-f0-9]{8}\.(js|css)$'

# Compare hash of the built file to expected
sha256sum dist/assets/main-a1b2c3d4.js

# Cross-check the manifest maps to an existing file
node -e "
  const fs = require('fs');
  const m = JSON.parse(fs.readFileSync('dist/asset-manifest.json', 'utf8'));
  let errors = 0;
  for (const [src, entry] of Object.entries(m)) {
    const url = typeof entry === 'string' ? entry : entry.url;
    const file = 'dist' + url;
    if (!fs.existsSync(file)) {
      console.error('Missing file for', src, '->', file);
      errors++;
    }
  }
  if (errors > 0) process.exit(1);
  console.log('All manifest entries resolve to files');
"

Step 2: CDN header inspection

# Inspect cache status from Cloudflare edge
curl -sI https://cdn.example.com/assets/main-a1b2c3d4.js \
  | grep -i -E 'cache-control|cf-cache-status|etag|age'

# Force a cold-cache miss to get a fresh edge response
curl -sI -H "Cache-Control: no-cache" \
  https://cdn.example.com/assets/main-a1b2c3d4.js \
  | grep -i 'cf-cache-status'

# Verify against multiple geographic edge IPs
for ip in 104.21.0.1 172.67.0.1; do
  echo "=== $ip ==="
  curl -sI --resolve cdn.example.com:443:$ip \
    https://cdn.example.com/assets/main-a1b2c3d4.js \
    | grep -i 'cf-cache-status'
done

Expected output: CF-Cache-Status: HIT after the first request warm-up. MISS on the first request is correct; EXPIRED or REVALIDATED on subsequent requests indicates the immutable directive was not applied.

Step 3: Hash drift detection between builds

# Build twice with identical source and compare manifests
npm run build && cp dist/asset-manifest.json /tmp/manifest-a.json
npm run build && cp dist/asset-manifest.json /tmp/manifest-b.json
diff /tmp/manifest-a.json /tmp/manifest-b.json

No diff means deterministic build outputs are working. Any diff reveals a non-deterministic plugin or timestamp injection problem.

Failure Modes and Gotchas

Wrong hash token in Webpack. Using [hash] instead of [contenthash] in Webpack output templates means every build produces a different hash for every chunk, regardless of what changed. All edges purge all assets on every deploy — defeating the immutability contract. Always use [contenthash] for JavaScript and CSS outputs.

Non-deterministic module IDs. Without moduleIds: 'deterministic' in Webpack 5, module IDs are assigned by discovery order. Adding, removing, or renaming any module shifts IDs and therefore hashes of unrelated chunks. This causes full-cache invalidation on what should be a minor code change.

Vite assetsInlineLimit inlining small files. The default 4 KB threshold causes small fonts and SVGs to be inlined as data URIs in the CSS. These inlined references cannot be individually cached or fingerprinted, and change the CSS hash every time any small asset changes. Set assetsInlineLimit: 0 unless you have profiled that the inline threshold improves performance for your specific page load pattern.

esbuild metafile output path confusion. The paths in result.metafile.outputs are relative to the current working directory, not to outdir. Construct the manifest paths carefully to avoid off-by-one prefix errors that send deployments to the wrong S3 key prefix.

Next.js public/ files have no hashing. Files under public/ are copied verbatim to the build output with their original filenames. Any cache-busting for these files must be done manually — either by versioning the filename, setting short TTLs, or using assetPrefix only for files under _next/static/.

Astro inlining stylesheets. With inlineStylesheets: 'auto' (the default), Astro inlines small stylesheets into <style> blocks. This removes them from the fingerprinted output and makes it impossible to cache them at the edge. Set inlineStylesheets: 'never' for consistent behavior.

CDN stripping immutable directive. Some CDN configurations — particularly default Nginx proxy setups — strip Cache-Control response headers and apply their own TTL. Verify the immutable token survives edge processing by running curl -sI against the CDN hostname, not the origin hostname.

Mixed-version asset serving during rolling deploy. If HTML uploads and asset uploads are not atomic — or if HTML is deployed before assets propagate — users may receive HTML referencing hashed asset URLs that do not yet exist on the CDN. Always upload assets before HTML, and verify propagation before switching traffic.

Pre-Deploy Checklist

  • ls dist/assets/ | grep -E '[a-f0-9]{8}')
  • [contenthash] (not [hash] or [chunkhash]) is used in Webpack output templates
  • moduleIds: 'deterministic' and chunkIds: 'deterministic' are set in Webpack 5 optimization
  • assetsInlineLimit: 0 is set if consistent fingerprinting of all assets is required
  • metafile: true is set and meta.json is captured as a build artifact
  • Cache-Control: public, max-age=31536000, immutable at the CDN
  • Cache-Control: no-cache, must-revalidate (not immutable)
  • CF-Cache-Status: HIT (or equivalent) on second request
  • integrity attributes are injected into <script> and <link> tags if cross-origin delivery is used

Frequently Asked Questions

Which hash token should I use in Webpack 5 — [hash], [chunkhash], or [contenthash]?

Always use [contenthash] for JavaScript and CSS outputs. [hash] is a build-wide identifier that changes when anything in the build changes, so every file gets a new URL on every deploy regardless of whether its content changed. [chunkhash] is chunk-scoped but includes the chunk graph, so adding a new import to one chunk can dirty hashes of unrelated chunks. [contenthash] is derived from the emitted bytes of each individual output file, giving stable hashes for files whose content did not change.

Does Vite’s [hash] token produce the same hash across two identical builds?

Yes, provided the build inputs are deterministic. Vite delegates hashing to Rollup, which uses a SHA-256 digest of the final emitted bytes. If any plugin injects a timestamp, random value, or environment-specific path into the emitted bytes, the hash will differ. Pin plugin versions, set NODE_ENV=production, and test determinism by running npm run build twice and diffing the manifests.

Should I purge CDN cache for fingerprinted assets after deploying?

No. Fingerprinted assets have unique URLs by definition — a new URL is always a cache miss, so no purge is needed. Only purge the HTML entry points (and any non-fingerprinted files) that reference the new hashed asset URLs. Purging fingerprinted asset paths is harmless but wasteful. The CDN purge strategies reference covers the exact purge scope for HTML-only invalidation.

How do I implement subresource integrity alongside content hashing?

Generate SHA-256 hashes of each built file at build time, encode them as base64, and inject integrity="sha256-<base64>" attributes into the <script> and <link> tags that reference fingerprinted assets. Store the integrity values in the asset manifest alongside the hashed URLs. The subresource integrity validation guide covers generating SRI hashes in the build pipeline for each major bundler.

What happens if two different source files produce the same 8-character hash?

A hash collision at 8 hex digits has a 1-in-4-billion probability per pair of files — extremely unlikely in practice for projects with fewer than a few thousand chunks. If a collision occurs, the second file silently overwrites the first in the output directory without error from most bundlers. For monorepos with thousands of chunks, raise to [contenthash:12] or [contenthash:16]. The tradeoff and risk analysis are covered in preventing hash collisions in large frontend projects.

Can I use the same fingerprinting approach across multiple build tools in a monorepo?

Yes. Normalize on a shared manifest format — a JSON file mapping source paths to hashed output URLs — regardless of which bundler generated it. Each package in the monorepo runs its own build and writes its own manifest; a post-build script merges them. Use 12-character hashes when multiple packages emit assets to a shared CDN path prefix to reduce the already-small collision probability further.