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.
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.
Step 4: Render relationships as real links
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.
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.
| 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
- Choosing a Content API Shape for SEO Builds — how the model is queried once the addressability decision is made
- Handling Catch-All Routes Without Duplicate URLs — the routing layer that resolves these stored paths
- Implementing SEO-Friendly Slug Normalization — the rules that produce the slug segment of each path