Per-Package vs Global Asset Manifests in a Monorepo

Once a workspace emits fingerprinted files, something has to translate src/main.tsx into /assets/checkout/main-a1b2c3d4.js at render time. The question is whether that lookup table lives beside each package’s build output or is merged into one file at the repository root — and the answer changes how you deploy, how you roll back, and how much JSON your servers parse on boot.

The Two Layouts, Concretely

Per-package. Each application’s bundler writes a manifest inside its own dist/, and nothing is ever combined:

apps/checkout/dist/.vite/manifest.json     # 38 KB, 412 entries
apps/account/dist/.vite/manifest.json      # 41 KB, 448 entries
apps/docs/dist/.vite/manifest.json         # 12 KB, 96 entries

Its contents are exactly what Vite emits, with keys relative to the package root:

{
  "src/main.tsx": {
    "file": "main-a1b2c3d4.js",
    "name": "main",
    "src": "src/main.tsx",
    "isEntry": true,
    "css": ["main-6b21f0ac.css"],
    "imports": ["_vendor-9f3e77b1.js"]
  },
  "_vendor-9f3e77b1.js": {
    "file": "vendor-9f3e77b1.js",
    "name": "vendor"
  }
}

Merged root. A build or deploy step folds every package’s manifest into one file, and every key is namespaced by the owning package:

{
  "@acme/checkout": {
    "prefix": "/assets/checkout/",
    "buildId": "2026-07-22T09:41:03Z-f0c19a",
    "entries": {
      "src/main.tsx": { "file": "main-a1b2c3d4.js", "isEntry": true }
    }
  },
  "@acme/account": {
    "prefix": "/assets/account/",
    "buildId": "2026-07-21T16:02:55Z-71bd4e",
    "entries": {
      "src/main.tsx": { "file": "main-a1b2c3d4.js", "isEntry": true }
    }
  }
}

Note that both packages produced main-a1b2c3d4.js. Because each namespace carries its own prefix, those resolve to different URLs — the mechanism the monorepo asset hashing guide sets up. What is not safe is the key src/main.tsx, which is identical in both packages. A merge that forgets the namespace destroys one of them.

Per-package manifests versus one merged root manifest On the left, three packages each write their own manifest into their own prefix with no shared write target. On the right, all three packages write into one root manifest file, creating write contention where the last deploy overwrites the others. Write targets decide deploy independence Per-package manifests Merged root manifest @acme/checkout dist/manifest.json @acme/account dist/manifest.json @acme/docs dist/manifest.json /assets/checkout/ own write target /assets/account/ own write target /assets/docs/ own write target three independent deploys @acme/checkout deploys 09:41 @acme/account deploys 09:42 @acme/docs deploys 09:42 assets-manifest last writer wins single write target one shared file, deploys race
The structural difference is not the JSON shape — it is how many pipelines are allowed to write the same object at the same time.

Comparison

Criterion Per-package manifest Merged root manifest
Render-time lookup cost ~40 KB parsed per package, loaded lazily on first use 300–600 KB parsed on every server boot (12 apps × ~400 chunks ≈ 4,800 entries)
Deploy independence Full — no shared write target exists Contended: two concurrent deploys read-modify-write the same object
Rollback granularity Restore one file, one app moves Restore the whole file, or surgically edit one namespace out of it
Collision risk None across packages; keys are package-scoped by construction Real: two packages sharing an entry key silently overwrite each other
CI artifact handling N artifacts, each attached to the run that built it 1 artifact, but it must be rebuilt whenever any package changes
Server template integration N lookups, one per mounted app; needs a resolver 1 lookup, trivially injectable into a template context
Preload / cache-warming generation Per app, matches the page actually being rendered Easy to over-generate preloads for apps not on the page
Staleness window Bounded by that package’s manifest TTL Bounded by the slowest app’s deploy; a stale merge can hide a fresh deploy

The table splits cleanly: per-package wins everything about writing, merged wins everything about reading. That is the whole trade, and it is why the hybrid at the end of this page exists.

Manifest strategy decision matrix Six criteria scored for each strategy: per-package manifests win on deploy independence, rollback granularity and collision safety, while a merged root manifest wins on artifact handling and server template wiring. Where each strategy wins Per-package Merged root Render-time lookup cost lazy 40 KB eager 500 KB Deploy independence full contended Rollback granularity one app all apps Cross-package key clash impossible silent loss CI artifact handling N artifacts one artifact Server template wiring needs resolver direct inject Per-package wins the write path; merged wins the read path
Scored per criterion: the two strategies do not compete on the same axis, which is why a hybrid beats either pure form.

The Merge Is Where Data Is Lost

The naive merge is one line, and it is wrong:

// Silently drops entries. Do not ship this.
const merged = Object.assign({}, checkoutManifest, accountManifest, docsManifest);

Every Vite manifest keys on the source path relative to its own package root. Three apps scaffolded from the same template all have src/main.tsx, src/router.ts, and index.html. After Object.assign, only the last one survives — and it survives with a file value that resolves against the wrong prefix. The failure is silent: valid JSON, plausible-looking entries, a 404 in production for two of the three apps.

A correct merge namespaces every package and refuses to continue if a namespace repeats:

// scripts/merge-manifests.mjs — run at DEPLOY time, from already-built dist/ trees.
import { readdir, readFile, writeFile, stat } from 'node:fs/promises';
import { join, resolve } from 'node:path';

const WORKSPACE = resolve(process.cwd());
const APPS_DIR = join(WORKSPACE, 'apps');
const OUT = join(WORKSPACE, 'dist', 'assets-manifest.json');

async function findManifest(appDir) {
  for (const candidate of ['dist/.vite/manifest.json', 'dist/manifest.json']) {
    const full = join(appDir, candidate);
    try {
      await stat(full);
      return full;
    } catch {
      continue;
    }
  }
  return null;
}

async function main() {
  const apps = await readdir(APPS_DIR, { withFileTypes: true });
  const merged = {};

  for (const dirent of apps) {
    if (!dirent.isDirectory()) continue;
    const appDir = join(APPS_DIR, dirent.name);
    const pkg = JSON.parse(await readFile(join(appDir, 'package.json'), 'utf8'));
    const manifestPath = await findManifest(appDir);

    if (!manifestPath) {
      throw new Error(`${pkg.name}: no manifest under ${appDir}/dist — build it first`);
    }
    if (Object.hasOwn(merged, pkg.name)) {
      throw new Error(`Duplicate package namespace "${pkg.name}" — refusing to merge`);
    }

    const short = pkg.name.includes('/') ? pkg.name.split('/')[1] : pkg.name;
    const entries = JSON.parse(await readFile(manifestPath, 'utf8'));

    merged[pkg.name] = {
      prefix: `/assets/${short}/`,
      buildId: process.env.BUILD_ID ?? new Date().toISOString(),
      entries,
    };
  }

  const names = Object.keys(merged);
  if (names.length === 0) throw new Error('No package manifests found — nothing to merge');

  await writeFile(OUT, `${JSON.stringify(merged, null, 2)}\n`, 'utf8');
  console.log(`merged ${names.length} package manifests -> ${OUT}`);
}

main().catch((err) => {
  console.error(err.message);
  process.exit(1);
});

Two properties make this safe. It runs at deploy time from built trees, never as a build task, so it is not something a cached build can restore in a partial state — the class of failure described in Turborepo cache-hit hash drift. And it throws instead of overwriting, converting a silent production 404 into a red pipeline.

What it still cannot fix is concurrency. Read-modify-write against an object store is not atomic across pipelines: if checkout and account both read the merged file at 09:41:58 and both write at 09:42:03, one set of entries disappears. Regenerating the whole file from every package’s dist/ on every deploy — as above — only works if every package’s dist/ is present, which means every deploy must rebuild or restore all packages. That is lockstep deployment reintroduced through the back door.

Rollback Granularity

Rollback is where the two strategies diverge most sharply in practice, because rollbacks happen under time pressure.

Rollback blast radius Rolling back the account app touches only its own manifest under the per-package strategy, but rewrites the single merged file and therefore reverts all three applications under the merged strategy. Per-package rollback checkout v41 manifest untouched account v18 to v17 restore one file docs v7 manifest untouched Merged root rollback one file rewritten, three apps affected checkout reverted too account rolled back docs reverted too Blast radius: one manifest object versus one shared file and every namespace inside it
Under a merged manifest, restoring a previous copy of the file reverts every namespace inside it — including apps that were fine.

With per-package manifests, rollback is aws s3 cp of one object from a previous build’s artifact. Nothing else on the origin changes; the hashed assets for both the old and new release are already there, since fingerprinted files are never overwritten.

With a merged manifest, you have two bad options. Restore the whole previous file and you silently revert every other app that deployed since — a rollback of account that also un-ships checkout’s morning release. Or hand-edit one namespace out of the current file, which means a read-modify-write in the middle of an incident, racing against any pipeline that happens to be running.

Runtime Lookup Under Per-Package Manifests

The read-path cost of per-package manifests is entirely solvable with a small resolver that fetches lazily and caches in process.

// server/resolve-asset.mjs
const CDN_ORIGIN = process.env.CDN_ORIGIN ?? '';
const cache = new Map();

async function loadManifest(pkg) {
  if (cache.has(pkg)) return cache.get(pkg);
  const short = pkg.includes('/') ? pkg.split('/')[1] : pkg;
  const url = `${CDN_ORIGIN}/assets/${short}/manifest.json`;

  const promise = fetch(url, { headers: { accept: 'application/json' } })
    .then((res) => {
      if (!res.ok) throw new Error(`manifest ${url} -> HTTP ${res.status}`);
      return res.json();
    })
    .then((entries) => ({ prefix: `/assets/${short}/`, entries }));

  cache.set(pkg, promise);
  return promise;
}

export async function resolveAsset(pkg, entry) {
  const { prefix, entries } = await loadManifest(pkg);
  const record = entries[entry];
  if (!record) throw new Error(`${pkg}: no manifest entry for "${entry}"`);
  return {
    js: `${prefix}${record.file}`,
    css: (record.css ?? []).map((f) => `${prefix}${f}`),
  };
}

export function invalidate(pkg) {
  cache.delete(pkg);
}

Two details matter. The cache stores the promise, not the resolved value, so ten concurrent requests during a cold start produce one fetch rather than ten. And invalidate() exists because manifests are the one file in the whole scheme that is mutable — the deploy script serves them with a short max-age and must-revalidate precisely so a new release becomes visible without a restart. The general pattern for wiring this into a template layer is covered in consuming an asset manifest in server-rendered templates.

Verification

Whichever strategy you pick, two invariants must hold before deploy: no two manifests claim the same hashed URL, and every file a manifest names exists.

# 1. No duplicate hashed filenames within any single package prefix.
for m in apps/*/dist/.vite/manifest.json; do
  ns=$(basename "$(dirname "$(dirname "$m")")")
  jq -r '.[] | .file, (.css // [])[]' "$m" \
    | sed "s#^#${ns}/#"
done | sort | uniq -d
# Expected: empty. Any line printed is a real URL collision.

# 2. Every file named by a manifest exists on disk.
for m in apps/*/dist/.vite/manifest.json; do
  dist=$(dirname "$(dirname "$m")")
  jq -r '.[] | .file, (.css // [])[]' "$m" | while read -r f; do
    test -f "$dist/$f" || echo "MISSING $dist/$f (named by $m)"
  done
done
# Expected: no output.

# 3. If you merge: prove the merge lost nothing.
node scripts/merge-manifests.mjs
per_pkg=$(jq -r '.[] | .file' apps/*/dist/.vite/manifest.json | wc -l)
merged=$(jq -r '.[].entries | .[] | .file' dist/assets-manifest.json | wc -l)
test "$per_pkg" -eq "$merged" && echo "merge preserved $merged entries" || echo "MERGE LOST ENTRIES"

Check 3 is the one that catches the Object.assign class of bug, and it is cheap enough to run on every pipeline. Wiring these into a required job is covered in validating asset manifests in CI.

The Hybrid Worth Defaulting To

Keep per-package manifests as the source of truth, and publish a small root index that carries nothing but pointers:

{
  "generatedAt": "2026-07-22T09:42:11Z",
  "packages": {
    "@acme/checkout": { "manifest": "/assets/checkout/manifest.json", "buildId": "f0c19a" },
    "@acme/account": { "manifest": "/assets/account/manifest.json", "buildId": "71bd4e" },
    "@acme/docs": { "manifest": "/assets/docs/manifest.json", "buildId": "9ce204" }
  }
}

This index is under 1 KB, so the server can load it eagerly on boot and re-fetch it cheaply. It gives you the discovery point a merged manifest provides — one place that answers “what is deployed right now” — without the shared write target. Each package still updates only its own manifest.json; the index is regenerated by the deploy job as a last step, and because it contains only pointers, a lost race costs you a stale buildId rather than a lost application.

When to Reconsider

Merge when the whole workspace is one deploy unit. A server-rendered application whose frontend is split into workspace packages for code organisation — not for independent release — has no write contention to avoid and no per-app rollback to preserve. One manifest injected into the template context at boot is simpler than a resolver, and the parse cost is paid once.

Merge also when the render layer genuinely needs cross-package data: a shell that emits preload hints for chunks belonging to apps it has not mounted yet needs to see every namespace at once. In that case, generate the merged file at deploy time from the per-package sources, treat it as a derived cache rather than a source of truth, and keep the per-package files on the origin so rollback still has a fine-grained target.

Stay per-package the moment two teams can ship on different days. The read-path cost is a resolver you write once; the write-path cost of a shared file is an incident you debug repeatedly.