Back to Engineering
GovernanceIn production6 min readgovernance.cms

Building a Component Inventory App for Contentful Studio

In Contentful Studio, authors assemble pages from a shared library of registered components, and any one of those components can end up on dozens of experiences. But Contentful gives you no complete, cross-experience inventory of that library — so teams can’t easily see which components exist, how heavily each one is used, which are barely used, or which are safe to retire.

Contentful App SDKReact + ViteForma 36REST manifestVitest
01

What the app provides

This is a native Contentful app that closes that gap. Installed on a Page location, it gives content and engineering teams a searchable inventory of the component library for the space and environment they’re already in. It evolved from an earlier CI-based audit, and its component registry now self-syncs from each brand application instead of being maintained by hand. For the current space, it provides:

  • Complete component inventory — every registered Studio component in one place.
  • Most-used and least-used rankings — components ranked by how many experiences use them and how often.
  • Experience-level usage drill-down — expand any component to see exactly which experiences render it, and how many times.
  • Search, filtering, and sorting — find a component by name or id, filter by category, sort by usage.
  • Full inventory export — the whole inventory to Excel: component summary, experience summary, site structure, and full detail.
  • Per-component migration-list export — a single component’s worklist — every experience using it — as its own sheet.
  • Registered, unregistered, and unused classifications — what’s known, what’s drifted, and what’s a retirement candidate.
02

From CI script to a native app

This started life as a command-line audit run through GitLab CI. It did a pattern-aware tree walk over every experience, mapped each definitionId to the pages that render it, and wrote a styled Excel workbook. Useful — but it had three rough edges that only showed up with use.

  • It was pull, not present. Someone had to remember the script existed, trigger the CI job, and download an artifact.
  • The output was a snapshot. An Excel from last Tuesday, not the live state of the space you’re looking at right now.
  • The registry drifted. Component names and categories lived in a list I hand-maintained; when a brand registered a new component in its app, the audit didn’t know until I updated it — anything unknown surfaced as category Unknown.

So I rebuilt it as a native Contentful app. The classification core carried straight over — fetch every experience through the app’s own CMA identity, walk each component tree, expand patterns once, and tally usage — but now it lives inside the CMS as an interactive inventory, and the registry became the app’s responsibility instead of mine.

03

The inventory, live in the CMS

The main view is the inventory itself: a ranked, sortable table of every component, each row showing its registry status, how many experiences use it, and its total number of uses, with a usage bar for quick scanning. Sort by experiences or by total uses to flip between most-used and least-used; search by name or id; filter by category. Expand a row to drill into the exact experiences a component appears in — the per-experience breakdown that used to require opening the spreadsheet.

Around that sit the views that make it a governance surface, not just a list:

  • A category mix and headline counts, so the shape of the library is visible at a glance.
  • An experience view — the inverse cut — showing which components each page renders.
  • A site-structure tree derived from experience slugs, flagging any subtree that contains an unregistered component.
  • An insights view that separates unregistered components (placed but not in the registry — drift) from unused registered components (in the registry but rendered nowhere — retirement candidates).

Everything exports. The whole inventory goes to Excel as component-summary, experience-summary, site-structure, and full-detail sheets; and any single component exports its own usage worklist — the exact list of experiences that render it. Excel because the people deciding what to build on or retire aren’t always engineers.

04

Deprecating a component safely

The reason all of this matters is lifecycle. In Studio the layout lives in the CMS, not in git, so you can’t grep the repo to find out who depends on a component before you change or remove it. The inventory turns that guess into a checklist, so retiring a component becomes a repeatable lifecycle process rather than a leap of faith:

  • Ship component v2.
  • Identify every experience still using v1. Search to the component and read its usage count and the exact experiences.
  • Export the migration list. The per-component worklist is the precise checklist of pages to move.
  • Migrate and verify. Swap v1 for v2 on each experience — by hand, or with the batch-update script for larger sets.
  • Confirm usage reaches zero. Refresh the inventory; v1 drops to zero uses and moves into the unused-registered list.
  • Retire v1 safely. With nothing rendering it, remove it from the registry and the codebase.
05

Each brand app publishes its registry

To kill the drift, I moved the source of truth to where it belongs: the brand application that actually registers the components. Each brand’s app now exposes a small JSON manifest at GET /api/studio-registry, built from the same list it already uses to register its Studio components — so it can never fall out of step with what’s really registered.

TSapp/api/studio-registry/route.ts
1// The brand app already registers its Studio components in one place —2// this endpoint serializes that list as the manifest the inventory reads.3export function GET() {4  const registry = buildStudioRegistry();5 6  return NextResponse.json({7    brand: 'ClearChoice',8    generatedAt: new Date().toISOString(),9    components: registry.components,       // [{ id, name, category }]10    builtInComponents: registry.builtIns,  // optional built-ins this brand enabled11  });12}
The manifest: the brand, its registered components, and any enabled built-ins.

The shape is deliberately boring — { brand, generatedAt, components: [{ id, name, category }], builtInComponents: [] }. CORS is open because the app fetches it from the Contentful web app’s origin, with a short cache so repeated loads don’t hammer the endpoint, plus an OPTIONS handler for the preflight.

06

Registry source of truth and graceful fallback

Each brand application already owns the list it uses to register its Studio components — so that list, published as the manifest, is the source of truth. On a successful fetch the inventory app fully replaces its bundled custom-component registry with the manifest rather than merging into it, which keeps the inventory from drifting away from what’s actually registered. It maps each entry to a name and category, falling back to the id and “Uncategorized” when either is missing. The one exception is built-ins: the universal set (Section, Container, columns, divider) is merged in last so it always keeps friendly names, even when a manifest only lists the optional built-ins a brand turned on.

Graceful fallback

A remote dependency can’t be allowed to take the tool down. So resolution is layered: the app looks up the manifest URL for its current space and environment, fetches it, and on any failure — no URL configured, network error, bad JSON, unexpected shape — falls back to the bundled per-brand registry and keeps going.

TSsrc/hooks/useReport.ts
1async function resolveRegistry(sources, registryKey, spaceId, environmentId) {2  const url = resolveRegistrySourceUrl(sources, spaceId, environmentId);3 4  // Nothing configured for this space + environment -> the bundled registry.5  if (!url) {6    return { registry: getRegistry(registryKey), status: 'fallback' };7  }8 9  try {10    const manifest = await fetchRegistryManifest(url);11    return { registry: manifestToRegistry(manifest), status: 'live' };12  } catch {13    // A remote failure never takes the inventory down.14    return { registry: getRegistry(registryKey), status: 'fallback' };15  }16}
Live manifest when it’s reachable; bundled registry when it isn’t.

The UI just reflects which path it took. When the live path wins, the header shows a small Live registry badge; when a configured URL fails, a warning explains it fell back and why. A normal, unconfigured space shows nothing — the bundled registry is a perfectly good default.

07

Configured per space and environment

Which URL applies to which space is set on the app’s config screen — a small table of space ID → environment → manifest URL rows, with a one-click “add current space & environment” and a live note showing which source matches right now. Resolution is most-specific-first: an exact space + environment match wins, then a space-wide row (blank or * environment), else nothing.

  • A per-row Test button fetches the manifest and reports the component count or the exact error, so you validate before saving.
  • Saving is blocked on malformed rows — a space ID needs a valid http(s) URL — so bad config never persists.
  • Because Contentful stores installation parameters per environment, each environment is configured on its own — the Testing setup doesn’t leak into master.
In the field

The script’s tell-tale was the Unknown category: whenever it appeared, it meant a real component had been registered in the app but not yet in my audit list, and I’d go reconcile the two by hand. It was a small, recurring tax I’d just accepted.

With the manifest wired up, that loop is gone. A brand registers a new component in its app, the app’s own endpoint reports it, and the next load of the inventory picks it up — correct name, correct category — with zero changes to the inventory app. The Live registry badge is the tool quietly telling me it’s reading reality, not a copy of it.

Production impact

  • Productized the original CI-based audit into a live, in-CMS capability — available to content and engineering teams without running a pipeline.
  • A live, searchable inventory ranks every component by experience count and total usage.
  • Safe deprecation is now a measurable workflow — an exact migration list, verified by usage reaching zero before retirement.
  • The component registry self-syncs from each brand application, so classifications stay trustworthy without hand-maintenance.
  • Graceful fallback keeps the inventory up even when a remote manifest is unreachable.
  • Configuration is isolated per space and environment.

Continue exploring

Engineering notes

Related case studies