esbuild Metafile to Asset Manifest

esbuild has no manifest: true option and no plan to add one, so a build that emits app-a1b2c3d4.js leaves your server with no way to answer “which file is app.js today?”. What esbuild does give you is metafile: true, and the metafile already contains every fact a manifest needs: the hashed output paths, the entry point that produced each one, and the stylesheet a JavaScript entry emitted on the side. This page turns that structure into a flat manifest.json you can ship.

The work splits into four decisions — which outputs become keys, what the key is called, what prefix the value carries, and whether the value also carries an integrity digest. Get those right and the generator is forty lines. The esbuild fingerprinting guide covers the naming templates that produce the hashes in the first place; this page assumes they are already set.

What the Metafile Actually Contains

Set metafile: true and esbuild returns result.metafile — a plain object with exactly two top-level keys, inputs and outputs. Here is a complete metafile for a two-file entry (src/app.ts importing src/app.css and lazily importing src/panel.ts), trimmed of nothing:

{
  "inputs": {
    "src/app.css": { "bytes": 1180, "imports": [] },
    "src/app.ts": {
      "bytes": 4210,
      "format": "esm",
      "imports": [
        { "path": "src/app.css", "kind": "import-statement" },
        { "path": "src/panel.ts", "kind": "dynamic-import" }
      ]
    }
  },
  "outputs": {
    "dist/app-a1b2c3d4.js": {
      "bytes": 39512,
      "inputs": { "src/app.ts": { "bytesInOutput": 3980 } },
      "imports": [
        { "path": "dist/chunks/panel-3a4b5c6d.js", "kind": "dynamic-import" }
      ],
      "exports": [],
      "entryPoint": "src/app.ts",
      "cssBundle": "dist/app-7e8f9a0b.css"
    },
    "dist/app-7e8f9a0b.css": {
      "bytes": 1042,
      "inputs": { "src/app.css": { "bytesInOutput": 1042 } },
      "imports": []
    },
    "dist/chunks/panel-3a4b5c6d.js": {
      "bytes": 8140,
      "inputs": { "src/panel.ts": { "bytesInOutput": 7902 } },
      "imports": [],
      "exports": ["mount"]
    }
  }
}

Read it from the outputs side. Every key is a path relative to the working directory, already carrying its hash. Only one of the three outputs has an entryPoint, and that field is the back-reference: it names a key in inputs, which is the logical identity the rest of your system knows the file by. The CSS output has no entryPoint at all — it is claimed instead by the JavaScript entry’s cssBundle field. The lazily imported chunk has neither, because nothing outside the bundle ever names it directly.

Metafile anatomy Two panels side by side. The left panel lists the inputs map with source files and byte counts. The right panel lists the outputs map, where the JavaScript output carries an entryPoint field pointing back into inputs and a cssBundle field naming the stylesheet, while the CSS output has no entryPoint of its own. One entry point, two outputs, one back-reference inputs src/app.ts bytes: 4210 imports: 2 entries src/app.css bytes: 1180 imports: 0 entries outputs dist/app-a1b2c3d4.js entryPoint: src/app.ts cssBundle: app-7e8f.css dist/app-7e8f9a0b.css entryPoint: absent bytes: 1042 entryPoint points back into inputs; that is the logical name to key on. cssBundle claims the stylesheet the JS entry emitted as a side effect.
The metafile is two maps joined by one field: entryPoint turns an output path back into a logical name.

Field by field, this is what survives into a manifest and what does not:

Metafile field Where it lives What it contributes
outputs key top level the hashed path on disk — the manifest value
entryPoint an outputs entry the logical name — the manifest key
cssBundle an outputs entry the stylesheet emitted by a JS entry, which has no entryPoint of its own
imports[] an outputs entry runtime-fetched chunks; the loader resolves these, so they are never manifest keys
bytes an outputs entry a cheap integrity signal to compare against the file on disk
inputs top level source-level attribution for bundle analysis; a manifest needs none of it

Choosing the Manifest Key

entryPoint gives you a source path, not a URL name. Turning src/pages/app.ts into something a template can reference is the one genuinely opinionated step, and the four reasonable answers behave differently once a project grows past a single package.

Key strategy Example key Breaks when Choose it when
Output basename app-a1b2c3d4.js never — but the key changes every build you only need a reverse lookup from URL to source
Entry basename app.js two entries in different folders share a filename one package, flat src/ layout
Path relative to outbase pages/app.js never within a single build nested entries, monorepo packages
Full entry path src/pages/app.ts never, but templates now hardcode .ts the consumer is a bundler-aware framework

The outbase-relative form is the one to reach for by default. It is stable across builds, unique within a build, and it mirrors the directory structure entryNames: '[dir]/[name]-[hash]' already produces on disk, so key and value stay visually parallel. Note the extension swap: the key must carry the output extension (.js), not the source extension (.ts), or every template lookup misses.

From one entry point to manifest keys A single entry point produces three outputs: the JavaScript entry output, a CSS bundle emitted as a side effect, and a shared chunk with no entryPoint. The JavaScript and CSS outputs each become a manifest key, while the shared chunk is reached through imports and is deliberately absent from the manifest. From one entry point to manifest keys src/app.ts one entry point app-a1b2c3d4.js has entryPoint app-7e8f9a0b.css named by cssBundle panel-3a4b5c6d.js shared, no entryPoint app.js manifest key app.css manifest key no key loaded via imports Only entryPoint outputs and their cssBundle become manifest keys.
Three outputs, two keys: the shared chunk is deliberately absent because the module loader resolves it, not your template.

The stylesheet you did not ask for

Any JavaScript entry that contains import './app.css' produces a second output file. Because that file was never an entry point, iterating outputs and skipping anything without an entryPoint silently drops it — a very common bug whose symptom is an unstyled page in production and a perfectly styled page in development. Since esbuild 0.19 the JS output carries cssBundle naming that file, so the fix is one branch inside the same loop rather than a second pass over the outputs.

Getting the Prefix Right: outdir, outbase, publicPath

Metafile keys are relative to the process working directory, so they start with your outdir. A browser must never see that segment. Two more options shift the path further: outbase decides how much of the source directory structure is preserved in the output tree, and publicPath decides what gets prepended to asset references inside the bundles. Your manifest values must agree with publicPath exactly, or the HTML and the CSS will disagree about where assets live.

From metafile key to browser URL Step one is the raw metafile outputs key including the outdir segment. Step two strips the outdir and outbase segments to leave a stable manifest-relative path. Step three prepends the publicPath prefix to produce the URL the browser actually requests. Three steps from metafile key to browser URL 1 · metafile outputs key dist/js/app-a1b2c3d4.js as esbuild wrote it 2 · strip outdir and outbase js/app-a1b2c3d4.js manifest key stays stable 3 · prepend publicPath cdn.example.com/js/app-a1b2c3d4.js what the browser requests
Strip what the build machine knows, prepend what the browser needs — and use the identical publicPath value in both places.

The Generator Script

This runs against a metafile already written to disk, which is the shape most CI pipelines want: the build job writes dist/meta.json, a later job turns it into dist/manifest.json. Save it as scripts/manifest-from-metafile.mjs.

// scripts/manifest-from-metafile.mjs
// Usage: node scripts/manifest-from-metafile.mjs dist/meta.json dist/manifest.json
import { createHash } from 'node:crypto';
import { readFileSync, writeFileSync, renameSync } from 'node:fs';
import path from 'node:path';

const [, , metaPath = 'dist/meta.json', outPath = 'dist/manifest.json'] = process.argv;

// These four must match the esbuild options the build actually used.
const OUTDIR = process.env.ESBUILD_OUTDIR ?? 'dist';
const OUTBASE = process.env.ESBUILD_OUTBASE ?? 'src';
const PUBLIC_PATH = process.env.CDN_ORIGIN ?? '/';
const WITH_INTEGRITY = process.env.WITH_INTEGRITY === '1';

const meta = JSON.parse(readFileSync(metaPath, 'utf8'));

/** src/pages/app.ts + dist/pages/app-a1b2c3d4.js  ->  pages/app.js */
function logicalKey(entryPoint, outputPath) {
  const rel = path.relative(OUTBASE, entryPoint);
  const dir = path.dirname(rel) === '.' ? '' : `${path.dirname(rel)}/`;
  const stem = path.basename(rel, path.extname(rel));
  return `${dir}${stem}${path.extname(outputPath)}`;
}

/** dist/pages/app-a1b2c3d4.js  ->  <publicPath>pages/app-a1b2c3d4.js */
function publicUrl(outputPath) {
  const rel = path.relative(OUTDIR, outputPath).split(path.sep).join('/');
  return PUBLIC_PATH.endsWith('/') ? PUBLIC_PATH + rel : `${PUBLIC_PATH}/${rel}`;
}

function entryValue(outputPath) {
  if (!WITH_INTEGRITY) return publicUrl(outputPath);
  const digest = createHash('sha384').update(readFileSync(outputPath)).digest('base64');
  return { url: publicUrl(outputPath), integrity: `sha384-${digest}` };
}

const manifest = {};

for (const [outputPath, info] of Object.entries(meta.outputs)) {
  if (outputPath.endsWith('.map')) continue;   // source maps are not manifest entries
  if (!info.entryPoint) continue;              // shared chunks resolve through imports

  manifest[logicalKey(info.entryPoint, outputPath)] = entryValue(outputPath);

  // A JS entry that imports CSS emits a stylesheet with no entryPoint of its own.
  if (info.cssBundle) {
    manifest[logicalKey(info.entryPoint, info.cssBundle)] = entryValue(info.cssBundle);
  }
}

// Sort keys so two identical builds produce byte-identical manifests.
const sorted = Object.fromEntries(
  Object.keys(manifest).sort().map((key) => [key, manifest[key]]),
);

const tmp = `${outPath}.tmp`;
writeFileSync(tmp, `${JSON.stringify(sorted, null, 2)}\n`);
renameSync(tmp, outPath);
console.log(`wrote ${Object.keys(sorted).length} entries to ${outPath}`);

Run it and inspect the result:

CDN_ORIGIN=https://cdn.example.com/ node scripts/manifest-from-metafile.mjs
cat dist/manifest.json
{
  "admin.js": "https://cdn.example.com/admin-e5f6a7b8.js",
  "app.css": "https://cdn.example.com/app-7e8f9a0b.css",
  "app.js": "https://cdn.example.com/app-a1b2c3d4.js"
}

Sorting the keys is not cosmetic. Object.entries follows insertion order, which follows esbuild’s output order, which can shift when an entry point is added — and an unsorted manifest produces a spurious diff on every release, defeating the build-to-build comparison that CI asset pipeline integration relies on to decide what to invalidate.

The Same Logic as a Plugin

If you drive esbuild from a build script rather than a CI shell step, the same mapping belongs in an onEnd hook so the manifest lands with the bundle and no intermediate meta.json has to exist. The plugin form also reads outdir and outbase from the live options object instead of environment variables, which removes the main failure mode of the standalone script — configuration drift between the build and the generator.

// plugins/manifest.mjs
import { createHash } from 'node:crypto';
import { readFileSync, writeFileSync, renameSync } from 'node:fs';
import path from 'node:path';

export function manifestPlugin({
  outfile = 'dist/manifest.json',
  publicPath = '/',
  withIntegrity = false,
} = {}) {
  return {
    name: 'metafile-manifest',
    setup(build) {
      build.initialOptions.metafile = true;   // the plugin cannot work without it

      build.onEnd((result) => {
        if (result.errors.length > 0 || !result.metafile) return;

        const outdir = build.initialOptions.outdir ?? 'dist';
        const outbase = build.initialOptions.outbase ?? 'src';
        const prefix = publicPath.endsWith('/') ? publicPath : `${publicPath}/`;

        const url = (p) => prefix + path.relative(outdir, p).split(path.sep).join('/');
        const key = (entryPoint, outputPath) => {
          const rel = path.relative(outbase, entryPoint);
          const dir = path.dirname(rel) === '.' ? '' : `${path.dirname(rel)}/`;
          return dir + path.basename(rel, path.extname(rel)) + path.extname(outputPath);
        };
        const value = (p) => {
          if (!withIntegrity) return url(p);
          const digest = createHash('sha384').update(readFileSync(p)).digest('base64');
          return { url: url(p), integrity: `sha384-${digest}` };
        };

        const manifest = {};
        for (const [outputPath, info] of Object.entries(result.metafile.outputs)) {
          if (outputPath.endsWith('.map') || !info.entryPoint) continue;
          manifest[key(info.entryPoint, outputPath)] = value(outputPath);
          if (info.cssBundle) {
            manifest[key(info.entryPoint, info.cssBundle)] = value(info.cssBundle);
          }
        }

        const sorted = Object.fromEntries(
          Object.keys(manifest).sort().map((k) => [k, manifest[k]]),
        );
        const tmp = `${outfile}.tmp`;
        writeFileSync(tmp, `${JSON.stringify(sorted, null, 2)}\n`);
        renameSync(tmp, outfile);
      });
    },
  };
}
// build.mjs
import { build } from 'esbuild';
import { manifestPlugin } from './plugins/manifest.mjs';

await build({
  entryPoints: ['src/app.ts', 'src/admin.ts'],
  bundle: true,
  minify: true,
  splitting: true,
  format: 'esm',
  outdir: 'dist',
  outbase: 'src',
  entryNames: '[dir]/[name]-[hash]',
  chunkNames: 'chunks/[name]-[hash]',
  assetNames: 'assets/[name]-[hash]',
  metafile: true,
  plugins: [
    manifestPlugin({
      publicPath: process.env.CDN_ORIGIN ?? '/',
      withIntegrity: true,
    }),
  ],
});

One ordering constraint: the digest is computed with readFileSync, so the plugin requires the default write: true. If your build sets write: false to post-process output in memory, hash file.contents from result.outputFiles instead — the bytes are identical, but the files are not on disk when onEnd runs.

Adding Integrity Digests

With withIntegrity: true each value becomes an object carrying a sha384- digest in the exact format the integrity attribute expects, so a template can emit both attributes from one lookup:

{
  "app.js": {
    "url": "https://cdn.example.com/app-a1b2c3d4.js",
    "integrity": "sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC"
  }
}

SHA-384 is the practical default: SHA-256 is also valid but browsers pick the strongest declared algorithm anyway, and the filename hash and the integrity digest serve different purposes — one addresses the file, the other authenticates it.

Verifying Every Manifest Target Exists

The failure this catches is a manifest that references a file the upload step never produced, which surfaces as a 404 for real users rather than a build error. Run it after the build and before the upload; it handles both the plain-string and the integrity-object value shapes.

#!/usr/bin/env bash
# scripts/verify-manifest.sh — every manifest target must exist under dist/
set -euo pipefail

MANIFEST="${1:-dist/manifest.json}"
OUTDIR="${2:-dist}"
PUBLIC_PATH="${CDN_ORIGIN:-/}"

missing=0
total=0

while IFS= read -r url; do
  total=$((total + 1))
  rel="${url#"$PUBLIC_PATH"}"
  if [ ! -f "$OUTDIR/$rel" ]; then
    echo "MISSING  $OUTDIR/$rel"
    missing=$((missing + 1))
  fi
done < <(jq -r '.[] | if type == "string" then . else .url end' "$MANIFEST")

echo "checked $total manifest targets, $missing missing"
[ "$missing" -eq 0 ]
CDN_ORIGIN=https://cdn.example.com/ bash scripts/verify-manifest.sh
# checked 3 manifest targets, 0 missing

The final [ "$missing" -eq 0 ] is the exit status, so the script fails the job under set -e without an explicit exit. Extend it to assert the reverse direction too — every hashed file on disk is named by the manifest — using the approach in validating asset manifests in CI.

When to Reconsider

Generating your own manifest is the right call whenever something resolves asset URLs at request time, but three situations argue the other way.

Nothing reads the manifest. If a static host serves dist/ directly and your HTML is itself a build output with the hashed names already inlined, the manifest is a file nobody opens. Skip it and keep metafile: true purely for CI diffing.

You need chunk-level dependency data. A flat key-to-URL map deliberately omits the imports graph. If you are emitting <link rel="modulepreload"> tags for an entry’s dynamic chunks, a flat manifest cannot answer the question and you need to walk metafile.outputs[...].imports at build time instead — or adopt the richer per-entry schema described in asset manifest generation.

You are already inside Vite or Rollup. Both emit a manifest natively with a superset of this schema. Running esbuild alongside them to produce a second, differently shaped manifest gives your server two sources of truth for the same question — pick one.

Frequently Asked Questions

Why does my CSS file never appear in the manifest?

Because the CSS output has no entryPoint field, so an if (!info.entryPoint) continue filter drops it. Read cssBundle on the JavaScript entry’s output instead, as the loop above does. If cssBundle is missing entirely, your esbuild is older than 0.19 — upgrade, or match the CSS output by swapping the extension on the JS output path, which works only while the two files share a hash-bearing basename.

Can I use esbuild’s own hashes instead of computing SHA-384?

Not for integrity. The hash in the filename is esbuild’s internal, undocumented digest of the output bytes; the integrity attribute requires a specific algorithm and base64 encoding that browsers verify. They are independent values with independent purposes, and you need both.

Should the manifest value include the origin, or just a path?

Whichever your publicPath uses — they must be identical strings. If publicPath is / and CSS files reference /assets/logo-3a4b5c6d.png, a manifest carrying absolute origins makes your HTML load from the CDN while your CSS loads from the app origin, which quietly doubles connections and breaks any CORS-sensitive asset.

Do source maps belong in the manifest?

No. A .map file is fetched by devtools from the sourceMappingURL comment inside the bundle, never by your templates. The generator skips them explicitly. Upload them (or do not, if they are private), but keep them out of the lookup table.