Mapping Content Models to Crawlable URL Structures

In a composable CMS the content model is a graph of types and references, and the URL structure is a decision about which of those nodes are destinations — a decision that is far easier to get right before launch than after.

When to Use This Approach

Work through this when:

  • Designing the content model for a new decoupled build, before any routes exist.
  • Search Console shows thin pages that correspond to component types rather than real destinations.
  • Path changes from a taxonomy reorganisation are generating large redirect batches.

Not Every Type Is a Destination

The single most consequential decision is which types are addressable. Everything else follows from it.

Addressable types compared with composable fragments Four content types with their own URLs on the left and four fragment types on the right that are composed into pages rather than addressed directly. addressable — gets a URL composable — no route article — has search intent of its own product — a destination people look for category — a browsable collection author — a genuine profile page hero banner — a layout fragment testimonial — quoted inside a page spec block — part of a product tag — often a thin archive, decide deliberately A type with no standalone intent and no useful page should have no route

Implementation Steps

Step 1: Classify every type before writing a route

# content-model/addressability.yml
addressable:
  article:   { path_pattern: "/blog/{slug}" }
  product:   { path_pattern: "/shop/{category}/{slug}" }
  category:  { path_pattern: "/shop/{slug}" }
  author:    { path_pattern: "/authors/{slug}" }
composable:
  - hero
  - testimonial
  - specBlock
  - ctaBanner
undecided:
  - tag        # needs a decision: archive page or facet?

Validation: Every type must appear in exactly one list. An undecided entry left at launch becomes a route somebody adds without an SEO decision.

Step 2: Store the path as a persisted field

type Article {
  id: ID!
  slug: String!
  path: String!          # "/blog/canonical-urls-in-headless" — stored, not derived
  previousPaths: [String!]!
  title: String!
  body: RichText!
}
// On save — the CMS owns the path, the front end only reads it
export function computePath(entry) {
  switch (entry.type) {
    case 'article':  return `/blog/${entry.slug}`;
    case 'product':  return `/shop/${entry.category.slug}/${entry.slug}`;
    case 'category': return `/shop/${entry.slug}`;
    case 'author':   return `/authors/${entry.slug}`;
    default: throw new Error(`type ${entry.type} is not addressable`);
  }
}

SEO impact: The URL becomes content rather than code, so a front-end refactor cannot silently change it and a path change is a reviewable content event.

Validation: Delete the path-derivation logic from the front end entirely. If anything breaks, the path was still being computed at render time somewhere.

Step 3: Keep the hierarchy shallow

Depth in the taxonomy does not have to become depth in the URL, and usually should not.

// Deep taxonomy, shallow paths
// taxonomy: Home > Footwear > Running > Trail > Waterproof
// path:     /shop/waterproof-trail-running-shoes/

export function categoryPath(category) {
  return `/shop/${category.slug}/`;               // one level, whatever the depth
}

SEO impact: Every category stays two clicks from the home page, and a taxonomy reorganisation changes breadcrumbs rather than URLs — which means no redirects.

Validation: Compute the maximum path depth across the site. Anything beyond three or four segments deserves justification.

The content graph is only an asset if it becomes a link graph.

// components/EntityLink.jsx — every reference renders as an anchor
export function EntityLink({ entity, children }) {
  if (!entity?.path) return <>{children}</>;      // composable types have no path
  return <a href={entity.path}>{children ?? entity.title}</a>;
}

// Used wherever a reference is rendered
<p>
  Reviewed by <EntityLink entity={article.author} />, filed under{' '}
  <EntityLink entity={article.category} />.
</p>

SEO impact: Related entries, authors, and categories become crawl paths, so deep content is discovered through relationships rather than depending on the sitemap alone.

Validation: curl -s <article url> | grep -c 'href="/' should reflect the number of references the entry actually has.

Step 5: Preserve path history

// On save — record the old path before overwriting
export async function savePath(entry) {
  const next = computePath(entry);
  if (entry.path && entry.path !== next) {
    const history = new Set(entry.previousPaths ?? []);
    history.add(entry.path);
    // Rewrite existing aliases to point at the new path, never chain them
    for (const old of history) await store.set(`alias:${old}`, next);
    entry.previousPaths = [...history];
  }
  entry.path = next;
  await store.put(entry);
}

SEO impact: Every historical URL redirects in one hop to the current one, which is what makes slug editing safe rather than a decision editors have to be warned about.

Validation: Change a slug twice and confirm the original URL redirects directly to the final path, not through the intermediate one.

Where Paths Come From

The difference between a derived path and a stored path shows up years later, when the front end is rewritten and the URLs are supposed to stay.

Derived paths compared with stored paths With derived paths, both the CMS and the front end can change a URL; with stored paths only the CMS can, and the front end merely reads the value. derived in the front end stored on the entity CMS fields route code the URL two owners — a refactor can change every URL CMS path field route code reads it the URL one owner — the front end cannot change a URL

SEO Impact Summary

Depth in the taxonomy and depth in the URL are separate choices, and keeping them separate is what makes a reorganisation cheap.

Deep taxonomy, shallow URLs A five-level taxonomy tree on the left mapped to two-segment URLs on the right, so hierarchy changes do not move URLs. taxonomy Footwear Running Trail Waterproof URL /shop/waterproof-trail-running-shoes/ Reorganising the tree changes breadcrumbs and internal links, not URLs
Signal What improves What breaks if misconfigured
URL stability Paths survive front-end rewrites and framework migrations Derived paths change whenever the code that derives them does
Index composition Only real destinations have URLs, so there are no component-level thin pages Addressable fragments create pages with no search intent
Link discovery Content relationships become crawl paths automatically Reference rendering without anchors leaves the graph invisible
Migration cost A taxonomy change updates breadcrumbs, not URLs Deep hierarchy-mirroring paths turn every reorganisation into a redirect project

Measurable signals to watch:

  • Maximum and median path depth across the site.
  • Ratio of addressable entries to indexed URLs, which should be close to one.
  • Count of internal links per content page, which should reflect the reference count in the model.

Edge Cases and Gotchas

A product in two categories needs one canonical path If the model allows multiple parents, the path must pick one deterministically — usually a designated primary category — and the other route should not exist rather than canonicalise. Two routes for one entry is a duplicate you created on purpose.

Localised paths belong on the entity too Each locale needs its own stored path, not a prefix applied to a shared slug, or transliterated slugs become impossible. The details are in handling multi-locale slug transliteration.

Tag archives are usually a facet, not a destination Most tag pages are thin, near-duplicate collections. Treat them as candidates for the same demand and inventory tests you would apply to a filter combination.

Editors will change slugs, so make it safe rather than forbidden Warning editors not to edit slugs does not work. Recording aliases automatically does, and it turns a risky operation into an ordinary one.

Frequently Asked Questions

Should the URL reflect the taxonomy hierarchy? Only where the hierarchy is stable and shallow. A category path that changes when merchandising reorganises produces mass redirects for no benefit, and deep taxonomies produce long URLs. Where taxonomy is volatile, keep paths flat and express hierarchy through breadcrumbs and links.

Should every content type have a URL? No. Giving components their own URLs is a common source of thin pages. A hero banner or specification block is a fragment, not a destination. No standalone intent means no route.

Where should the path be stored? On the entity, as a field the CMS owns. Deriving it at render time makes the URL depend on front-end code, so a refactor can change every URL. A stored path also makes alias history, sitemap generation, and exact-match routing straightforward.

How does this interact with a page-builder model? It reinforces the distinction: the page is addressable, the blocks composed into it are not. A page builder that lets editors create blocks with their own URLs has blurred the line and will produce thin pages within a quarter.


Part of: Composable CMS Architecture Basics

Related