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.
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.
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.
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