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.
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.
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.
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.
The operational pattern:
- Upload hashed assets to the object store. Nothing is live yet because nothing references them.
- Upload the manifest under a versioned key:
manifests/${GIT_SHA}.json. - Copy that object to the stable key the application reads:
manifests/current.json. This copy is the atomic cutover. - To roll back, copy a prior
manifests/<sha>.jsonovercurrent.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.
Related
- Choosing a Webpack manifest plugin — output shapes, integrity support and build overhead compared
- Resolving hashed URLs at request time — boot-time loading, caching, and Node plus Django helper functions
- Failing CI on a broken manifest — a runnable validator and the GitHub Actions job that runs it
- Reproducible build output — why an unstable manifest is a build problem, not a manifest problem
- Static asset fingerprinting fundamentals — parent overview