Asset Manifest Generation

Once a bundler renames app.js to app.3f8a2c1d.js, nothing else in your stack can find that file by name any more — the manifest is the lookup table that restores the connection between the logical name your code writes and the hashed URL that actually exists on the CDN. Get it wrong and every downstream consumer breaks at once: templates emit 404s, the service worker precaches phantom URLs, integrity attributes point at bytes that were never shipped, and rollback tooling has no record of what the previous release contained.

When to Emit a Manifest Instead of Injecting HTML

Many teams skip the manifest because their bundler already rewrites index.html for them. That works exactly until a second consumer appears. The decision comes down to how many independent systems need to resolve a hashed name.

Situation Inject into HTML only Emit a machine-readable manifest
Single-page app, one index.html, bundler owns the template Sufficient Optional
Server-rendered app (Django, Rails, Express, PHP) Not possible — the server owns the template Required
Service worker precache list Not possible — needs a URL array Required
SRI digests applied outside the bundler Fragile — must re-parse HTML Required
Rollback to a prior release’s exact file set No record exists Required
Multiple apps sharing one CDN bucket Collides Required, one per app
CI wants to assert what was built before uploading Nothing to assert against Required

The rule of thumb: HTML injection is a rendering convenience; a manifest is a contract. The moment a system other than the bundler needs to know a hashed filename, you need the contract. In practice this happens on nearly every production deployment, which is why every major bundler ships a manifest mode even when it also rewrites HTML.

Prerequisites

Requirement Minimum version Notes
Node.js 18.17 Required by Vite 5 and current plugin ecosystems
Webpack 5.89 [contenthash] plus webpack-manifest-plugin 5.x
Vite 5.0 build.manifest writes .vite/manifest.json
Rollup 4.9 generateBundle hook exposes the full bundle object
esbuild 0.20 metafile: true plus entryNames templates
Deployment target Any object store Manifest ships alongside the assets it describes

Content hashes must already be stable before a manifest is worth anything — a manifest that changes on every CI run for unchanged source is a manifest nobody can diff. Confirm your build is reproducible first using the deterministic build outputs guide.

What Belongs in a Manifest Record

A minimal manifest is a flat { "logical name": "hashed path" } object. That is enough for a template helper and nothing else. A production manifest carries four distinct pieces of information per file, each serving a different consumer.

Anatomy of one manifest record A manifest record joins four fields: the stable logical key, the hashed filename on disk, an integrity digest, and the byte size plus MIME type. Together they let a template emit a fully-formed script tag. ONE RECORD, FOUR CONSUMERS logical key src/main.js never changes hashed file main.3f8a2c1d.js what is on disk integrity sha384-9dK2wq feeds SRI attrs size + type 184320 bytes budgets, preload Template asks for src/main.js and gets /static/main.3f8a2c1d.js with the digest and byte size attached to the same lookup Drop any one field and a downstream consumer has to re-derive it by reading the file system.
A production manifest record carries the logical key, the emitted filename, an integrity digest and the byte size — one lookup serves the template, SRI, preload hints and budget checks.

Entry points. The subset of outputs a page actually links directly. A 400-chunk build has perhaps three entry points; everything else is loaded transitively. Consumers that render <script> tags only care about entries.

The chunk graph. Which chunks each entry statically imports, and which it dynamically imports. Without this you cannot emit modulepreload hints, and you cannot precache a route’s dependencies in a service worker without precaching the entire bundle.

Integrity digests. A base64 SHA-384 digest per file, prefixed sha384-. Computing this at manifest time is far cheaper than re-reading every file later, and it pins the digest to the exact bytes the bundler emitted rather than to whatever ended up on disk after a post-processing step.

Per-file size. Both raw and compressed size, if you can get it. This is what turns a manifest into a budget gate: CI can fail a pull request when the main entry crosses 200 KB without any extra tooling.

Manifest Shapes Across Bundlers

The five common shapes differ enough that a consumer written against one will not read another. Knowing which shape you have is the first step in writing a helper.

Manifest formats compared A matrix comparing a flat manifest.json, webpack stats.json, Vite's .vite/manifest.json, the esbuild metafile and the Rollup bundle object across three axes: record shape, chunk graph coverage and built-in integrity digests. WHAT EACH BUNDLER HANDS YOU FORMAT SHAPE CHUNK GRAPH INTEGRITY flat manifest.json name → hashed path none plugin option webpack stats.json verbose, megabytes complete no digests vite manifest.json per-entry records imports + css no digests esbuild metafile inputs and outputs imports listed compute yourself rollup bundle object in-memory, JS API complete compute in plugin Only the flat map is portable. Everything richer is bundler-specific and needs a normalising step.
Five manifest formats compared: the flat map is universal but thin, while the graph-carrying formats are each shaped by their own bundler's internals.

Flat manifest.json

{
  "main.js": "/static/main.3f8a2c1d.js",
  "main.css": "/static/main.9b4e7f02.css",
  "vendor.js": "/static/vendor.c1d5e8a3.js",
  "logo.svg": "/static/logo.7e2b91c4.svg"
}

Readable by anything that parses JSON, in any language. No graph, no digests, no sizes. This is the right output format even when your bundler produces something richer — normalise into it, then extend with sibling keys.

Webpack stats.json

Produced by webpack --json > stats.json or stats: { ... } in config. It contains every module, every chunk, every reason a module was included — routinely 20–80 MB on a large app. It is a debugging artefact, not a deploy artefact. You can extract a manifest from it, and the trade-offs of doing so are covered in webpack-manifest-plugin vs assets-manifest.

Vite .vite/manifest.json

{
  "src/main.ts": {
    "file": "assets/main-3f8a2c1d.js",
    "name": "main",
    "src": "src/main.ts",
    "isEntry": true,
    "css": ["assets/main-9b4e7f02.css"],
    "imports": ["_vendor-c1d5e8a3.js"]
  },
  "_vendor-c1d5e8a3.js": {
    "file": "assets/vendor-c1d5e8a3.js",
    "name": "vendor"
  }
}

Keyed by source path, not output name. Records prefixed _ are shared chunks with no source entry of their own. The css array is the field that saves you from the most common server-rendering bug — CSS emitted as a side effect of a JS chunk has no key of its own.

esbuild metafile

Keyed by output path, with an entryPoint back-reference and per-import records. It reports bytes for free, which makes it the cheapest source of a size-aware manifest. Converting one is covered in the esbuild fingerprinting plugins section.

Rollup bundle object

Never written to disk by Rollup itself. Inside a generateBundle hook you receive an object keyed by output filename whose values are OutputChunk or OutputAsset records — including code or source, so you can hash the bytes in memory without a second read. The Rollup manifest recipe walks through a plugin that does exactly that.

Configuration Reference

Key Bundler Type Default Effect
build.manifest Vite 5 boolean | string false Writes .vite/manifest.json; a string overrides the path
build.ssrManifest Vite 5 boolean false Emits a second module-ID → chunk map for SSR preload
base Vite 5 string / Prefix baked into file values in the manifest
output.publicPath Webpack 5 string auto Prefix prepended to every emitted URL
fileName manifest-plugin string manifest.json Output filename, relative to output.path
publicPath manifest-plugin string inherits Override the prefix for manifest values only
generate manifest-plugin function flat map Full control over the emitted object
writeToFileEmit manifest-plugin boolean false Also write to disk under webpack-dev-server
integrity assets-manifest boolean false Adds a sha384- digest per file
entrypoints assets-manifest boolean false Adds an entrypoints grouping alongside the flat map
metafile esbuild 0.20 boolean false Returns the build graph on the result object
entryNames esbuild 0.20 string [dir]/[name] Hash template for entry outputs
output.entryFileNames Rollup 4 string | fn [name].js Hash template; [hash] defaults to 8 chars in Vite

Hash length is 8 hex characters in every example here. Monorepos emitting thousands of chunks should move to 12–16; the reasoning and the collision arithmetic live in safely truncating content hash length.

Step-by-Step Implementation

1. Emit a Manifest From Webpack 5

Install the plugin and wire it into a production config. This version adds integrity digests and a size field through the generate hook, so the manifest is complete rather than a bare name map.

npm install --save-dev webpack@5.89 webpack-cli webpack-manifest-plugin@5
// webpack.config.js
const path = require('node:path');
const fs = require('node:fs');
const crypto = require('node:crypto');
const { WebpackManifestPlugin } = require('webpack-manifest-plugin');

const OUT_DIR = path.resolve(__dirname, 'dist');

function digestOf(absPath) {
  const bytes = fs.readFileSync(absPath);
  return 'sha384-' + crypto.createHash('sha384').update(bytes).digest('base64');
}

module.exports = {
  mode: 'production',
  entry: {
    main: './src/main.js',
    admin: './src/admin.js',
  },
  output: {
    path: OUT_DIR,
    publicPath: '/static/',
    filename: '[name].[contenthash:8].js',
    chunkFilename: '[name].[contenthash:8].chunk.js',
    assetModuleFilename: 'media/[name].[contenthash:8][ext]',
    clean: true,
  },
  optimization: {
    runtimeChunk: 'single',
    moduleIds: 'deterministic',
    splitChunks: { chunks: 'all' },
  },
  plugins: [
    new WebpackManifestPlugin({
      fileName: 'asset-manifest.json',
      publicPath: '/static/',
      generate: (seed, files, entrypoints) => {
        const assets = {};
        for (const file of files) {
          const abs = path.join(OUT_DIR, file.path.replace(/^\/static\//, ''));
          assets[file.name] = {
            url: file.path,
            integrity: fs.existsSync(abs) ? digestOf(abs) : null,
            bytes: fs.existsSync(abs) ? fs.statSync(abs).size : 0,
          };
        }
        return {
          version: process.env.GIT_SHA || 'local',
          generatedAt: new Date().toISOString().slice(0, 10),
          assets,
          entrypoints,
        };
      },
    }),
  ],
};

Two details matter. moduleIds: 'deterministic' stops module renumbering from churning hashes on unrelated edits. And generate receives entrypoints already grouped by entry name — that is the structure a template helper wants, because rendering an entry means emitting every file in its group in order.

Run it:

npx webpack --mode production
cat dist/asset-manifest.json | head -30

2. Emit a Manifest From Vite 5

Vite needs one flag, but the useful work is normalising its source-keyed shape into something a server can query by logical name.

// vite.config.js
import { defineConfig } from 'vite';
import { createHash } from 'node:crypto';
import { writeFileSync } from 'node:fs';
import { resolve } from 'node:path';

function flatManifest({ outFile = 'asset-manifest.json', base = '/static/' } = {}) {
  return {
    name: 'flat-asset-manifest',
    apply: 'build',
    generateBundle(_options, bundle) {
      const assets = {};
      for (const [fileName, chunk] of Object.entries(bundle)) {
        const bytes =
          chunk.type === 'chunk'
            ? Buffer.from(chunk.code)
            : Buffer.from(chunk.source);
        const key =
          chunk.type === 'chunk' && chunk.isEntry
            ? `${chunk.name}.js`
            : fileName;
        assets[key] = {
          url: base + fileName,
          integrity:
            'sha384-' + createHash('sha384').update(bytes).digest('base64'),
          bytes: bytes.length,
        };
      }
      this.emitFile({
        type: 'asset',
        fileName: outFile,
        source: JSON.stringify({ version: process.env.GIT_SHA || 'local', assets }, null, 2),
      });
    },
  };
}

export default defineConfig({
  base: '/static/',
  build: {
    manifest: true,
    outDir: 'dist',
    rollupOptions: {
      input: {
        main: resolve(__dirname, 'src/main.ts'),
        admin: resolve(__dirname, 'src/admin.ts'),
      },
      output: {
        entryFileNames: 'assets/[name]-[hash:8].js',
        chunkFileNames: 'assets/[name]-[hash:8].js',
        assetFileNames: 'assets/[name]-[hash:8][extname]',
      },
    },
  },
  plugins: [flatManifest()],
});

You now get both artefacts: Vite’s own .vite/manifest.json (graph, CSS side effects, entry flags) and your flat asset-manifest.json (digests, sizes, stable keys). Server-side helpers read the flat one; preload-hint generation reads Vite’s.

npx vite build
ls dist/.vite/manifest.json dist/asset-manifest.json

3. Emit a Manifest From esbuild 0.20+

// build.mjs
import * as esbuild from 'esbuild';
import { createHash } from 'node:crypto';
import { readFileSync, writeFileSync } from 'node:fs';

const result = await esbuild.build({
  entryPoints: ['src/main.ts', 'src/admin.ts'],
  bundle: true,
  minify: true,
  splitting: true,
  format: 'esm',
  outdir: 'dist/assets',
  entryNames: '[name]-[hash]',
  chunkNames: 'chunk-[hash]',
  assetNames: '[name]-[hash]',
  metafile: true,
});

const assets = {};
for (const [outPath, meta] of Object.entries(result.metafile.outputs)) {
  const bytes = readFileSync(outPath);
  const key = meta.entryPoint
    ? meta.entryPoint.replace(/^src\//, '').replace(/\.tsx?$/, '.js')
    : outPath.replace(/^dist\//, '');
  assets[key] = {
    url: '/' + outPath.replace(/^dist\//, 'static/'),
    integrity: 'sha384-' + createHash('sha384').update(bytes).digest('base64'),
    bytes: meta.bytes,
    imports: meta.imports.filter((i) => i.kind !== 'require-resolve').map((i) => i.path),
  };
}

writeFileSync(
  'dist/asset-manifest.json',
  JSON.stringify({ version: process.env.GIT_SHA || 'local', assets }, null, 2),
);
console.log(`wrote ${Object.keys(assets).length} manifest records`);

esbuild truncates [hash] to 8 characters by default and gives you meta.bytes without a stat call, so this is the lowest-overhead path of the four.

4. Emit a Manifest From Rollup 4

// rollup.config.mjs
import { createHash } from 'node:crypto';

function manifestPlugin({ base = '/static/' } = {}) {
  return {
    name: 'asset-manifest',
    generateBundle(_options, bundle) {
      const assets = {};
      for (const [fileName, item] of Object.entries(bundle)) {
        const bytes = Buffer.from(item.type === 'chunk' ? item.code : item.source);
        assets[item.type === 'chunk' && item.isEntry ? `${item.name}.js` : fileName] = {
          url: base + fileName,
          integrity: 'sha384-' + createHash('sha384').update(bytes).digest('base64'),
          bytes: bytes.length,
          imports: item.type === 'chunk' ? item.imports : [],
        };
      }
      this.emitFile({
        type: 'asset',
        fileName: 'asset-manifest.json',
        source: JSON.stringify({ assets }, null, 2),
      });
    },
  };
}

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

Emitting the manifest through this.emitFile rather than fs.writeFileSync keeps it inside Rollup’s own output accounting, so --dry-run style tooling and downstream plugins see it.

Manifest consumers A bundler build emits a single asset manifest, which is then read by server-rendered templates, the service worker precache list, Subresource Integrity attribute generation, preload hint emission, and rollback tooling. Bundler build hash + emit Asset manifest one JSON file Server-rendered templates Service worker precache Integrity attributes Preload and prefetch hints Rollback and deploy tooling Five consumers, one source of truth. Skip it and each one guesses.
The manifest is the single join point between a build and everything that needs to name a hashed file — templates, workers, integrity attributes, resource hints and rollback.

The publicPath and base Prefix Question

The hardest recurring bug in manifest consumption is deciding whether the manifest stores main.3f8a2c1d.js, /static/main.3f8a2c1d.js, or https://cdn.example.com/static/main.3f8a2c1d.js. All three are defensible; mixing them is not.

Stored form Works when Breaks when
Bare filename Consumer always knows the prefix Two apps share a bucket with different prefixes
Root-relative (/static/…) Single origin, fixed mount point App is mounted under a path prefix in one environment
Absolute CDN URL CDN host is fixed at build time Staging and production use different CDN hosts

The rule that survives environment drift: store root-relative paths in the manifest, and let the consumer prepend a host at render time. A root-relative path is unambiguous inside the deployment, and prefixing https://cdn.example.com at render time is a string concatenation the server can vary per environment. Baking the host in at build time forces a rebuild for every environment, which immediately produces different hashes per environment for identical source — the exact failure mode the content hashing guide warns about.

If you must bake in an absolute host, do it in a post-build rewrite step over the manifest only, never over the bundle, so the hashed files themselves stay environment-independent:

# Rewrite manifest URLs for a specific CDN host without rebuilding
CDN="https://cdn.example.com"
jq --arg cdn "$CDN" \
  '.assets |= map_values(.url = $cdn + .url)' \
  dist/asset-manifest.json > dist/asset-manifest.cdn.json

Committing the Manifest vs Building It

Committing a generated manifest into version control is tempting — it makes the file available to a server process that never runs the bundler. It is almost always wrong.

Build it. The manifest is derived data with a one-to-one relationship to a set of files. Committing it creates two sources of truth that drift the moment someone rebuilds without committing, and it produces a merge conflict on every parallel branch that touches the frontend.

The exception is a repository where the backend deploys independently of the frontend and cannot fetch a build artefact. Even then, prefer publishing the manifest to the same object store as the assets and having the backend fetch it at boot — described in consuming an asset manifest in server-rendered templates — over committing it.

If you do commit it, add a CI check that rebuilds and diffs, so a stale committed manifest fails the pull request instead of production:

npm run build
git diff --exit-code -- public/asset-manifest.json \
  || { echo "committed manifest is stale — commit the rebuilt file"; exit 1; }

Manifests in Atomic Deploys and Rollback

Fingerprinted files are additive: a deploy uploads new hashed names next to the old ones and deletes nothing. That property is what makes rollback cheap — but only if you kept a record of which files belonged to which release. That record is the manifest.

Manifest swap during rollback A two-lane timeline. The upper lane tracks hashed files present on the CDN across four deploy stages; the lower lane tracks which manifest version the application is serving. Rollback changes only the lower lane. ROLLBACK IS A MANIFEST SWAP T0 build T1 upload T2 publish T3 rollback Files on the CDN v1 hashes only nothing new yet v2 hashes added v1 untouched both generations live side by side unchanged no purge needed Manifest in use v1 manifest app serves v1 v1 manifest not switched yet v2 manifest atomic swap v1 restored fetched from S3 Old hashed files are never deleted, so a rollback rewinds one JSON file rather than re-uploading a release.
Across a deploy the CDN only ever gains files; the only thing that changes at cutover — and the only thing rewound on rollback — is which manifest the application reads.

The operational pattern:

  1. Upload hashed assets to the object store. Nothing is live yet because nothing references them.
  2. Upload the manifest under a versioned key: manifests/${GIT_SHA}.json.
  3. Copy that object to the stable key the application reads: manifests/current.json. This copy is the atomic cutover.
  4. To roll back, copy a prior manifests/<sha>.json over current.json. No rebuild, no re-upload, no CDN purge of asset URLs.
# Publish
aws s3 cp dist/asset-manifest.json "s3://$BUCKET/manifests/$GIT_SHA.json" \
  --cache-control "public,max-age=30"
aws s3 cp "s3://$BUCKET/manifests/$GIT_SHA.json" "s3://$BUCKET/manifests/current.json" \
  --cache-control "public,max-age=30"

# Roll back to a known-good release
aws s3 cp "s3://$BUCKET/manifests/$PREVIOUS_SHA.json" "s3://$BUCKET/manifests/current.json" \
  --cache-control "public,max-age=30"

Keep current.json on a short TTL — 30 to 60 seconds — so a rollback propagates in under a minute. Everything it points at stays on max-age=31536000, immutable. The wider pipeline shape is covered in the CI/CD asset pipeline section.

Verification

# 1. Every URL in the manifest exists in the build output
jq -r '.assets[].url' dist/asset-manifest.json \
  | sed 's|^/static/|dist/|' \
  | xargs -I{} sh -c 'test -f "{}" || echo "MISSING: {}"'

# 2. Every emitted file is referenced by the manifest (no orphans)
comm -23 \
  <(find dist -type f \! -name 'asset-manifest.json' | sed 's|^dist/|/static/|' | sort) \
  <(jq -r '.assets[].url' dist/asset-manifest.json | sort)

# 3. Recorded integrity digests match the bytes on disk
jq -r '.assets | to_entries[] | "\(.value.url) \(.value.integrity)"' dist/asset-manifest.json \
  | while read -r url want; do
      file="dist/${url#/static/}"
      got="sha384-$(openssl dgst -sha384 -binary "$file" | openssl base64 -A)"
      [ "$got" = "$want" ] || echo "DIGEST MISMATCH: $file"
    done

# 4. Two consecutive builds produce an identical manifest
npm run build && cp dist/asset-manifest.json /tmp/m1.json
rm -rf dist && npm run build && diff /tmp/m1.json dist/asset-manifest.json
# Silence = the manifest is reproducible.

# 5. The live manifest matches what the CDN actually serves
curl -s https://cdn.example.com/manifests/current.json \
  | jq -r '.assets[].url' \
  | xargs -I{} curl -o /dev/null -s -w "%{http_code} {}\n" "https://cdn.example.com{}" \
  | grep -v '^200'

Command 5 is the one worth putting on a schedule: it is the only check that compares the manifest against reality rather than against the build directory. Automating the first four as a hard gate is the subject of validating asset manifests in CI.

Edge Cases and Known Issues

Multi-Entry Applications and Shared Chunks

With two or more entries, splitChunks will hoist common code into a shared chunk that belongs to no single entry. A flat name map cannot express “entry admin needs vendor-c1d5e8a3.js before its own code”. You need the entrypoint grouping, and you need to emit files in the order the grouping gives you — a shared chunk loaded after the entry that depends on it will throw at parse time under format: 'es'. Both webpack-manifest-plugin (via generate’s third argument) and webpack-assets-manifest (via entrypoints: true) expose this; Vite exposes it as the imports array per record.

CSS Emitted as a Side Effect of a JS Chunk

When a JS module imports a stylesheet, the bundler extracts a hashed CSS file that has no source key of its own. In Vite’s manifest it appears only inside the entry’s css array; in Webpack with MiniCssExtractPlugin it appears as a separate main.css key. Consumers that iterate over “entry points” and emit <script> tags will silently drop the stylesheet, producing an unstyled first paint that looks like a CDN failure. Always render the css array before the script tag.

Manifests That Go Stale Relative to the CDN

The most damaging failure is a manifest that is internally consistent and externally wrong: it names files that were never uploaded, usually because the upload step partially failed or ran against the wrong bucket prefix. The symptom is a burst of 404s on hashed URLs immediately after cutover, with the HTML itself serving fine. Two defences: publish the manifest strictly after the asset upload completes and returns success, and add the live check from command 5 as a post-deploy smoke test that can trigger an automatic manifest rollback.

Development Servers Do Not Emit Manifests

Vite’s dev server serves modules from memory and writes no manifest at all; webpack-dev-server keeps output in memory unless writeToFileEmit is set. A server-rendered app therefore needs a development branch in its helper that returns the unhashed path directly rather than looking anything up. Forgetting this produces a helper that works in production and throws on every local page load.

Hash Length Changes Silently Invalidate Consumers

Moving from [contenthash:8] to [contenthash:12] changes every filename in one commit. That is fine for the manifest, which is regenerated, but it invalidates any hard-coded expectation elsewhere — a CDN cache rule with [a-f0-9]{8} in its path regex will stop matching, and those assets will fall back to the default (short) TTL. Audit path regexes whenever hash length changes.

Two Apps, One Bucket

Two independent builds writing asset-manifest.json to the same prefix will overwrite each other. Namespace by app: manifests/storefront/current.json and manifests/admin/current.json. The per-package variant of this problem in a monorepo is covered in per-package vs global asset manifests.

Performance Impact

Manifest work Added build time (500-file build) Added artefact size
Flat name map only 10–30 ms 20–40 KB
Plus per-file byte size 30–60 ms (one stat per file) +8 KB
Plus SHA-384 digests, in-memory 120–250 ms +45 KB
Plus SHA-384 digests, re-reading from disk 400–900 ms +45 KB
Full stats.json emission 2–6 s 20–80 MB

The lesson is in the last two rows. Hashing bytes you already hold in memory (Rollup’s chunk.code, Vite’s bundle object) costs a fraction of hashing bytes you re-read from disk, and emitting a full stats.json costs an order of magnitude more than every other option combined — which is why it belongs behind a flag rather than in the default production build.

At runtime the cost is effectively zero: a parsed manifest for a 500-file build occupies well under a megabyte of heap, and a helper that resolves a key is a plain object property access. The only runtime cost worth measuring is re-reading the manifest from disk per request, which is a real regression on a busy server and is the first thing to fix in a server-side helper.

Frequently Asked Questions

Do I need a manifest if my bundler already writes index.html for me?

Only if something other than that HTML file needs a hashed URL. As soon as you add a service worker precache list, apply SRI digests outside the bundler, emit preload headers from a CDN worker, or want CI to assert what was built, you need a machine-readable record. Emitting one costs milliseconds, so the pragmatic answer is to emit it from day one rather than retrofitting it during an incident.

Should the manifest live next to the assets or inside the application deployment?

Next to the assets, on a short TTL, fetched by the application at boot. That keeps the manifest and the files it describes in one atomic unit, and it lets a rollback rewind the manifest without redeploying the application. The exception is an application that must boot with no network access, which has to bake the manifest into its image and accept a redeploy for every rollback.

Why does my manifest change on every CI run even though the source did not?

Because something in the build is non-deterministic — a timestamp, an absolute path, module ordering that depends on filesystem enumeration, or a dependency resolved to a different patch version. The manifest is just reporting what the bundler produced; fix the build, not the manifest. Run the double-build diff in the verification section, then work through the deterministic build outputs guide.

Can one manifest cover images and fonts as well as JS and CSS?

Yes, and it should. Any file whose name carries a hash needs an entry, or templates that reference it will hard-code a filename that changes on the next build. In Webpack this means routing media through assetModuleFilename; in Vite and Rollup it means assetFileNames. The manifest then becomes the single lookup for every fingerprinted file in the deployment regardless of type.