Building a Route Manifest from CMS Content

This guide, part of Route Mapping for Multilingual Sites, builds the data structure that every other routing feature depends on: a manifest that maps each published entry and locale to its public path, and each path back to its entry. It covers the full build, incremental updates from webhooks, fast lookups at request time, and the validations that keep the manifest trustworthy.

Without a manifest, each part of a site computes paths on its own. The page router matches a slug, the navigation builds links from parent chains, the sitemap generator walks content types, and the hreflang builder guesses which locales exist. Each implementation has its own edge cases, and eventually they disagree: the sitemap lists a path the router cannot resolve, or a menu links to a page under its old parent. A manifest computes paths once and makes every consumer read the same answer.

One manifest, many consumersThe route manifest, built from CMS content, is read by the page router for path-to-entry lookups, by navigation and rich text links for entry-to-path lookups, by the sitemap generator, by the hreflang builder and by the redirect generator when paths change.CMS contentall localesRoute manifestid + locale ↔ pathPage routerNavigation+ linksSitemaphreflangRedirects
Every feature that needs a path reads it from the manifest instead of computing it.

The Problem

A publisher’s site had grown to 30,000 pages in five locales. Paths were computed in six places. After a content reorganization moved a section under a new parent, the router and sitemap picked up the new paths, but the navigation code cached parent chains separately and kept linking to old paths, which returned 404 because no redirects existed. It took two days to find all the places that computed paths and a week to add redirects for pages that had already been crawled at the old paths.

How the Manifest Works

Entries. Each manifest entry holds the entry id, locale, path, content type, parent id and the publication revision it was built from. Two indexes make it useful: path to entry for routing, and entry plus locale to path for linking.

Full build. A scheduled or on-deploy job queries all published routable entries in all locales, reconstructs each path from the parent chain and localized slugs, validates the result and swaps it in atomically.

Incremental updates. Publish, unpublish, slug-change and move events from CMS webhooks update the affected entries and their descendants. When a path changes, the old path is written to the redirect table at the same time.

Fast lookups. The router reads the manifest from memory or an edge key-value store, never from the CMS per request.

A section move, handled incrementallyAn editor moves a section under a new parent; the webhook handler recomputes paths for the section and its descendants in each locale, writes old-to-new redirects, updates the manifest in the edge store and revalidates navigation, sitemaps and affected pages.EditorCMSManifest updaterEdge storemove section under new parententry.publish (parent changed)recompute subtree pathsin all localeswrite new paths + redirectsrevalidate nav, sitemap, pages
One event updates paths, redirects and caches together, so nothing links to a dead path.

Implementation

The full build queries a flat list and computes paths with a memoized parent walk, so deep trees are processed in linear time.

TypeScript
// lib/routes/build.ts
interface Row { id: string; locale: string; slug: string; parentId: string | null; type: string; revision: number }
export interface ManifestEntry { id: string; locale: string; path: string; type: string; parentId: string | null; revision: number }

export function buildManifest(rows: Row[], maxDepth = 5): { entries: ManifestEntry[]; problems: string[] } {
  const byKey = new Map(rows.map((r) => [`${r.id}:${r.locale}`, r]));
  const memo = new Map<string, string | null>();
  const problems: string[] = [];

  const pathOf = (key: string, depth = 0): string | null => {
    if (memo.has(key)) return memo.get(key)!;
    const row = byKey.get(key);
    if (!row) return null;                                   // parent not published in this locale
    if (depth > maxDepth) { problems.push(`too deep or cyclic: ${key}`); return null; }
    const parentPath = row.parentId ? pathOf(`${row.parentId}:${row.locale}`, depth + 1) : `/${row.locale}`;
    const path = parentPath === null ? null : `${parentPath}/${row.slug}`;
    memo.set(key, path);
    return path;
  };

  const entries: ManifestEntry[] = [];
  const seen = new Map<string, string>();
  for (const r of rows) {
    const path = pathOf(`${r.id}:${r.locale}`);
    if (!path) continue;
    if (seen.has(path)) { problems.push(`duplicate path ${path}: ${seen.get(path)} and ${r.id}`); continue; }
    seen.set(path, r.id);
    entries.push({ id: r.id, locale: r.locale, path, type: r.type, parentId: r.parentId, revision: r.revision });
  }
  return { entries, problems };
}

The job publishes the manifest only if problems is empty or contains only known, accepted issues; otherwise it keeps the previous manifest live and alerts. It writes two key sets to the edge store, path:{path} → {id, locale, type} and entry:{id}:{locale} → path, plus a version key so readers can detect updates.

The incremental updater handles a publish event by recomputing the changed entry and all descendants in the affected locales, comparing with the stored paths, and writing new keys, deleting stale ones and adding redirects for every changed path.

TypeScript
// lib/routes/update.ts
import { kv } from "@/lib/edge-kv";
import { addRedirect } from "@/lib/redirects";

export async function applyPathChanges(changes: { id: string; locale: string; oldPath: string | null; newPath: string | null; type: string }[]) {
  for (const c of changes) {
    if (c.oldPath && c.oldPath !== c.newPath) {
      await kv.delete(`path:${c.oldPath}`);
      if (c.newPath) await addRedirect(c.oldPath, c.newPath, 301);   // moved or renamed
    }
    if (c.newPath) {
      await kv.put(`path:${c.newPath}`, JSON.stringify({ id: c.id, locale: c.locale, type: c.type }));
      await kv.put(`entry:${c.id}:${c.locale}`, c.newPath);
    } else {
      await kv.delete(`entry:${c.id}:${c.locale}`);                 // unpublished: 404 or 410 from now on
    }
  }
}

Reading the manifest

The router resolves an incoming path with one key lookup; a miss checks the redirect table, then returns 404. Link resolution in components calls entry:{id}:{locale}, falling back to the fallback chain’s locales when the target is not published in the reader’s locale. Cache lookups in memory within a request, and keep the edge store as the shared source across instances.

Configuration Reference

Item Recommendation Why
Keys path:{path} and entry:{id}:{locale} Both directions in one lookup.
Full build on deploy and nightly Heals drift from missed webhooks.
Incremental publish, unpublish, move, slug change Paths update within seconds.
Publishing atomic, only if validation passes A bad build never goes live.
Redirects written with every path change Old links keep working.
Depth limited, for example 5 Cycles and runaway trees fail fast.

Gotchas & Edge Cases

  • Descendants in every locale. Moving a parent changes descendant paths in every locale where they are published. Recompute the whole subtree per locale.
  • Unpublished parents. Children of an unpublished parent have no path. Decide whether to exclude them or attach them elsewhere, and apply the rule in both full and incremental builds.
  • Race conditions. Two rapid publishes can be processed out of order. Compare revisions and ignore older events, as in idempotent webhook handlers.
  • Case and normalization. Store paths in their canonical form, lowercase and without trailing slashes if that is the convention, and normalize incoming paths the same way before lookup.

Worked Example

The publisher built the manifest with a nightly full build and webhook-driven incremental updates, stored in an edge key-value store, and changed the router, navigation, sitemap and hreflang builder to read from it. The next reorganization moved 1,800 pages in five locales; the updater recomputed 9,000 paths, wrote 9,000 redirects and revalidated navigation within a minute. No 404s from internal links were recorded afterwards, and search console showed the redirects being followed within days.

Internal-link 404s after a section reorganization404 responses caused by internal links in the week after a major section reorganization, before the manifest existed and after it was introduced.Paths computed in six places5200 404s in the following weekSingle route manifest0 404s in the following week
With one manifest and automatic redirects, a reorganization no longer breaks links.

Validating the Manifest Continuously

The manifest is only as good as its agreement with the CMS and the live site, so check that agreement regularly. The nightly full build doubles as a reconciliation: compare its result with the incrementally maintained store and report differences by kind, missing paths, extra paths and changed paths. A handful of differences a week is normal and indicates missed webhooks; a large number indicates a bug in the incremental path. After deploys, sample manifest entries per locale and request their paths, expecting 200 responses whose canonical equals the manifest path, and sample redirect entries, expecting a single 301 to a 200. These checks keep the manifest the source of truth rather than one more cache that can drift.

Rollout Checklist

  • Define manifest entries and two lookup directions.
  • Build the full manifest with validation for duplicates, depth and cycles.
  • Update incrementally from webhooks, including descendants and redirects.
  • Serve lookups from memory or an edge store, never from the CMS per request.
  • Move router, navigation, sitemap and hreflang onto the manifest.
  • Reconcile nightly and sample live paths after deploys.

Frequently Asked Questions

Can the CMS compute paths for us?

Some CMSs store a computed URL field per entry and locale, which helps. You still need the manifest for locale-aware lookups, validation and redirects, but it can use that field as input.

How big can the manifest get?

Hundreds of thousands of entries fit comfortably in edge key-value stores. Keep each value small, just ids and types, and fetch everything else from the CMS as needed, cached with the page that uses it.

What about pages that are not in the CMS?

Add code-defined routes, such as search or account pages, to the manifest from configuration, with a path per locale, so links, language switchers and sitemaps treat them the same way as CMS pages.

How quickly must incremental updates apply?

Within seconds of a publish, before the page’s own revalidation completes, so that links and redirects are already correct when the new page first renders for readers.

Should the manifest include drafts?

No. Keep a separate draft resolution for preview; the production manifest must only ever contain published, publicly reachable paths.