Generating a Rollup Asset Manifest for CDN Deploys
Without a manifest, deploying hashed Rollup output to a CDN is a guessing game: the HTML template has no reliable way to know that app.js is now assets/app-3f9a2c1b.js. This page walks through writing a complete, production-ready manifest plugin and wiring it into a CDN deploy script.
Why you need a manifest
Rollup’s [hash] token gives each output file a content-derived name that changes only when the content changes. That solves cache invalidation elegantly — browsers keep serving the old file from cache until the filename changes. The problem is the other side of that coin: your HTML templates and server-side renderers need to know the hashed filename ahead of time.
A manifest is a JSON file that maps each logical asset name (the name you reference in code) to the hashed output filename that Rollup actually wrote to disk. Example output:
{
"app.js": "assets/app-3f9a2c1b.js",
"admin.js": "assets/admin-7e2d1a4c.js",
"vendor-react.js": "assets/vendor-react-0b5f8e3a.js",
"main.css": "assets/main-c4a9d2f1.css",
"logo.svg": "assets/logo-1d7c3b8e.svg"
}
A server-side template reads this file at startup and replaces every <script src="app.js"> reference with the correct hashed URL. A CI/CD pipeline reads it to know exactly which URLs to prime on the CDN after upload. Neither the template engine nor the deploy script ever touches the dist/ directory structure directly — the manifest is the contract between the build and the runtime.
The alternative — embedding hashed filenames directly into HTML during the build — works for static sites but breaks for server-rendered applications where HTML is assembled at request time. The manifest approach works for both.
Decision matrix: manifest strategies
| Approach | Mechanism | Works for SSR? | Works for static? | CDN-deploy friendly? |
|---|---|---|---|---|
| Manifest JSON file | generateBundle plugin emits JSON |
Yes | Yes | Yes |
| Inline hash in HTML | Plugin rewrites index.html at build |
No (build-time only) | Yes | Partial |
| Import meta URL | Runtime import.meta.url |
No (needs bundler runtime) | No | No |
| Query-string versioning | Append ?v=hash to URLs |
Partial | Yes | No (CDNs ignore query strings by default) |
The manifest JSON approach is the only one that satisfies all four requirements for a CDN deploy workflow. The cache-key architecture reference explains in detail why query-string versioning is unreliable at the CDN layer.
The generateBundle hook
Rollup exposes two hooks that run after all output files are finalized:
generateBundle(outputOptions, bundle, isWrite)— fires before files are written to disk; you can still callthis.emitFile()here and Rollup will include the emitted file in the write pass.writeBundle(outputOptions, bundle)— fires after files are written; you cannot emit new files but you can read the completeddist/directory.
For a manifest, use generateBundle. Calling this.emitFile inside it makes Rollup write the manifest as part of the normal output pass, which means it appears in the bundle object of any later plugin and is included in any watch-mode rebuild. writeBundle is the right hook for triggering external side effects like CDN uploads (it guarantees files exist on disk).
Complete manifest plugin
The following plugin is production-ready. It handles entry chunks, code-split chunks, and non-JS assets (CSS, images, fonts) in a single pass.
// plugins/manifest.js
/**
* Rollup plugin that emits a manifest.json mapping logical asset names
* to their hashed output filenames.
*
* Usage: add manifestPlugin() to the plugins array in rollup.config.mjs
*/
export function manifestPlugin({ fileName = 'manifest.json' } = {}) {
return {
name: 'asset-manifest',
generateBundle(outputOptions, bundle) {
const manifest = {};
for (const [outputFileName, chunk] of Object.entries(bundle)) {
if (chunk.type === 'chunk') {
// Entry chunks have a reliable `name` from the input object key.
// Dynamic/split chunks use their facadeModuleId-derived name or fall
// back to the output filename.
const key = chunk.isEntry && chunk.name
? `${chunk.name}.js`
: outputFileName;
manifest[key] = outputFileName;
} else if (chunk.type === 'asset') {
// Assets: CSS, images, fonts, and any file emitted by other plugins.
// chunk.name may be undefined for programmatically emitted assets;
// fall back to the output filename as both key and value.
const key = chunk.name ?? outputFileName;
manifest[key] = outputFileName;
}
}
this.emitFile({
type: 'asset',
fileName,
source: JSON.stringify(manifest, null, 2)
});
}
};
}
Wire it into your config alongside the output options from the Rollup asset optimization reference:
// rollup.config.mjs
import resolve from '@rollup/plugin-node-resolve';
import commonjs from '@rollup/plugin-commonjs';
import terser from '@rollup/plugin-terser';
import { manifestPlugin } from './plugins/manifest.js';
export default {
input: {
app: 'src/main.js',
admin: 'src/admin.js'
},
output: {
dir: 'dist',
format: 'es',
hashCharacters: 'hex',
entryFileNames: 'assets/[name]-[hash:8].js',
chunkFileNames: 'assets/[name]-[hash:8].js',
assetFileNames: 'assets/[name]-[hash:8][extname]',
manualChunks(id) {
if (id.includes('node_modules/react') || id.includes('node_modules/react-dom')) {
return 'vendor-react';
}
if (id.includes('node_modules/')) {
return 'vendor';
}
}
},
plugins: [
resolve({ browser: true }),
commonjs(),
terser(),
manifestPlugin()
]
};
After npx rollup -c, the dist/ directory contains:
dist/
manifest.json
assets/
app-3f9a2c1b.js
admin-7e2d1a4c.js
vendor-react-0b5f8e3a.js
vendor-0c4d1e9f.js
main-c4a9d2f1.css
And manifest.json contains:
{
"app.js": "assets/app-3f9a2c1b.js",
"admin.js": "assets/admin-7e2d1a4c.js",
"assets/vendor-react-0b5f8e3a.js": "assets/vendor-react-0b5f8e3a.js",
"assets/vendor-0c4d1e9f.js": "assets/vendor-0c4d1e9f.js",
"main.css": "assets/main-c4a9d2f1.css"
}
Entry chunks (app.js, admin.js) get clean keys because chunk.isEntry && chunk.name is truthy. Vendor chunks — which have no entry-point key in the input object — fall back to the output filename as both key and value. That is intentional: vendor chunks are referenced via dynamic import from application chunks, not from HTML directly, so the server template does not need to look them up.
Key derivation and key stability
The value side of a manifest entry is expected to change on every content change — that is the whole point. The key side must not. A key that moves invalidates every template that references it, and because templates are usually deployed separately from the build that produced the manifest, a moved key surfaces as a hard 500 at render time rather than a build error.
Three sources feed the key, and only one of them is under your direct control.
Entry-chunk keys come from the input object in the config, so renaming app to main in input silently renames the manifest key. Treat the input keys as a public interface and change them with the same care as a URL.
Generated-chunk keys come from Rollup’s derived chunk name, which is computed from the first module in the chunk. Adding a module that sorts earlier can change that name even when nothing else moved, which is why the plugin above uses the full output path for non-entry chunks instead — the path is at least self-consistent within a single build. Naming those chunks yourself with manualChunks and stable chunk hashes removes the derivation entirely and gives you a key you can safely reference.
Asset keys come from the name property the emitting plugin set. Plugins that call this.emitFile({ type: 'asset', fileName }) without a name produce entries with no stable key at all. If a CSS or font entry keeps changing key between builds, that is the cause; wrap the plugin or post-process the bundle to assign a name explicitly.
A cheap guard is to assert the key set in CI: keep a committed manifest-keys.txt and fail the build if the sorted key list drifts. That converts a runtime template error into a build-time diff.
CDN deploy script
The deploy script reads manifest.json and uploads every listed output file to an object store, then primes the CDN. The example targets AWS S3 + CloudFront, with notes for Cloudflare R2.
#!/usr/bin/env bash
# deploy-cdn.sh
# Usage: CDN_DISTRIBUTION_ID=EXXXXXXX S3_BUCKET=my-bucket ./deploy-cdn.sh
set -euo pipefail
DIST_DIR="dist"
MANIFEST="${DIST_DIR}/manifest.json"
S3_BUCKET="${S3_BUCKET:?S3_BUCKET env var required}"
CDN_DISTRIBUTION_ID="${CDN_DISTRIBUTION_ID:-}"
echo "==> Uploading hashed assets (immutable cache headers)"
# Upload everything under dist/assets/ with a one-year cache lifetime.
# These filenames contain a content hash, so they are safe to cache forever.
aws s3 sync "${DIST_DIR}/assets/" "s3://${S3_BUCKET}/assets/" \
--cache-control "public, max-age=31536000, immutable" \
--metadata-directive REPLACE \
--delete
echo "==> Uploading manifest (short TTL)"
# The manifest itself is not fingerprinted — always upload it with no-cache
# so servers pick up the new version immediately.
aws s3 cp "${MANIFEST}" "s3://${S3_BUCKET}/manifest.json" \
--cache-control "no-cache, no-store, must-revalidate"
echo "==> Invalidating HTML entry points on CloudFront"
if [[ -n "${CDN_DISTRIBUTION_ID}" ]]; then
aws cloudfront create-invalidation \
--distribution-id "${CDN_DISTRIBUTION_ID}" \
--paths "/index.html" "/manifest.json"
fi
echo "==> Deploy complete"
For Cloudflare R2, replace the aws s3 sync calls with the Wrangler R2 commands or the Cloudflare API. The cache-control header strategy is identical: hashed assets get immutable, the manifest and HTML get no-cache.
Why the upload order is not negotiable
The manifest is the only unhashed file in the deploy, so it is the only one that can be wrong. Upload it before the files it names and every request served in that interval resolves to a URL that does not exist yet.
The window is not theoretical. On a large bundle an aws s3 sync of several hundred hashed files takes tens of seconds, and every user who loads the page during that interval gets a blank screen from a failed module fetch. The --delete flag makes it worse in the other direction: it removes the previous release’s hashed files, so any browser still holding an old HTML document loses its assets too. Run the sync without --delete on the deploy path and prune old objects on a separate schedule, well after the release has settled.
The mirror image of this rule applies during a rollback. Restore the old manifest only after confirming the old hashed files are still present in the bucket — if a prune job already removed them, rolling the manifest back points production at objects that no longer exist.
HTML injection at runtime
A Node.js server reads manifest.json once at startup and exposes a helper that templates use to produce the correct <script> and <link> tags:
// server/assets.js
import { readFileSync } from 'fs';
import { join } from 'path';
const MANIFEST_PATH = join(process.cwd(), 'dist', 'manifest.json');
let manifest = {};
export function loadManifest() {
const raw = readFileSync(MANIFEST_PATH, 'utf8');
manifest = JSON.parse(raw);
}
export function assetUrl(logicalName) {
const hashed = manifest[logicalName];
if (!hashed) {
throw new Error(`Asset not found in manifest: ${logicalName}`);
}
// Prepend CDN origin in production
const base = process.env.CDN_ORIGIN ?? '';
return `${base}/${hashed}`;
}
In an Express template or a framework server component:
// server/index.js
import express from 'express';
import { loadManifest, assetUrl } from './assets.js';
loadManifest();
const app = express();
app.get('/', (req, res) => {
res.send(`<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>My App</title>
<link rel="stylesheet" href="${assetUrl('main.css')}">
</head>
<body>
<div id="root"></div>
<script type="module" src="${assetUrl('app.js')}"></script>
</body>
</html>`);
});
app.listen(3000);
When the build produces a new hash for app.js, assetUrl('app.js') returns the new URL automatically on the next server restart. No template edits needed.
This pattern integrates directly with CI/CD asset pipeline workflows where the manifest is uploaded as a build artifact and the server restarts after deployment.
Verification
# 1. Confirm manifest was emitted and is valid JSON
cat dist/manifest.json | python3 -m json.tool
# 2. Check that every value in the manifest points to an existing file
node -e "
const m = JSON.parse(require('fs').readFileSync('dist/manifest.json', 'utf8'));
let ok = true;
for (const [key, file] of Object.entries(m)) {
const exists = require('fs').existsSync('dist/' + file);
if (!exists) { console.error('MISSING:', file); ok = false; }
}
if (ok) console.log('All', Object.keys(m).length, 'manifest entries resolve to files.');
"
# 3. Verify hash appears in every asset filename
node -e "
const m = JSON.parse(require('fs').readFileSync('dist/manifest.json', 'utf8'));
const unhashed = Object.values(m).filter(f => !/[a-f0-9]{8}/.test(f));
if (unhashed.length) { console.error('No hash found in:', unhashed); process.exit(1); }
console.log('All filenames contain a hex hash.');
"
# 4. Check S3 object metadata after upload
aws s3api head-object \
--bucket my-bucket \
--key "assets/app-3f9a2c1b.js" \
--query 'CacheControl'
# Expected: "public, max-age=31536000, immutable"
When to reconsider
The manifest approach adds a file-read on every server startup and a key-lookup on every HTML render. For static sites built with a tool that writes HTML at build time (Astro, Eleventy, Hugo), the manifest intermediary is unnecessary — the build tool can inject hashed URLs directly into the HTML output. See the Astro build-time hashing guide for that pattern.
The manifest is also not needed if you serve assets from a path that carries the build hash at the directory level (for example, /release/abc123/assets/app.js). In that case the HTML simply embeds the release hash once, and all asset references are relative. The trade-off is that this invalidates every asset on every release, even unchanged ones — the opposite of content-addressed deterministic build outputs.
FAQ
Should the manifest itself be fingerprinted?
No. Something has to be fetchable at a fixed URL or the chain of indirection never terminates. Serve manifest.json with Cache-Control: no-cache, no-store, must-revalidate and let every hashed file it names carry a one-year immutable lifetime. If you want to avoid the extra request entirely, read the manifest at server startup and inline the resolved URLs into the HTML you render.
Why does my manifest only contain half the assets?
The bundle object only contains files Rollup emitted through this.emitFile. A plugin that writes with fs.writeFileSync — several CSS and image plugins default to this — produces files on disk that never appear in generateBundle. Check the plugin for an option that switches it to the emit path, and if there is none, add a writeBundle hook that scans the output directory and merges the missing entries.
Does generateBundle run once per output when I have multiple outputs?
Yes. An output array fires generateBundle once per entry, each time with that output’s own bundle object. A plugin that emits a fixed manifest.json will therefore write a different manifest into each output directory, and the last one wins if they share a directory. Derive the manifest filename from outputOptions.dir or outputOptions.format when you build more than one output.
Can I generate the manifest in writeBundle instead?
You can read the bundle there, but you cannot call this.emitFile — the write pass is already done. You would have to write the file yourself with fs.writeFileSync, which puts the manifest outside Rollup’s own output accounting and breaks watch-mode cleanup. Use generateBundle for producing the file and writeBundle only for side effects such as the CDN upload.
Related
- Rollup asset optimization — parent page covering output options, hashCharacters, and manualChunks
- Build tool & framework asset pipeline integration — full build-tools reference
- CI/CD asset pipeline integration — how the manifest fits into GitHub Actions deploy workflows
- Content hashing vs semantic versioning — why content-addressed filenames outperform version strings for cache safety