Dynamic CMS linking: why your 'Link' field is a time bomb
Most CMS implementations let authors paste raw URLs into a text field. The day someone renames a slug, half your navigation silently 404s. Here is the model that makes that impossible.
The Tuesday morning incident
On a Tuesday morning, a marketer renames a campaign page in the CMS. They change /campaigns/spring-2024 to /campaigns/spring-launch. Three nav items, two homepage cards, and a paid-ad landing page now point at a 404. Nobody notices for eleven days. Paid traffic kept arriving the whole time.
This is not an author error. This is a model error. The CMS handed them a text field and a footgun.
The default: a text field
In the average CMS setup, "link to another page" is implemented as a string field. The author is expected to remember the URL, type or paste it correctly, and update every reference by hand if it ever changes.
There is no validation that the URL exists. No warning if the target is unpublished, deleted, or moved. No way for the CMS to tell you, six months later, "these 47 places link to a page that doesn't exist anymore." The agency that built it shipped on time. The cost was deferred onto the author and onto your traffic.
Why it breaks at scale
It doesn't take a redesign to break. The everyday operations of a content team break it:
- Slug edits. SEO renames a page for keyword reasons. Every text-field link pointing at the old slug breaks.
- Locale variants. The English author types
/about. The Danish version is/da/om-os. Half the localized pages link out of locale. - Parent/child moves. A page moves under a new parent. Its full path changes. Every link to it is now wrong.
- Deletes and unpublishes. A page comes down. Every text link to it points at a 404 forever.
- Domain or scheme changes. Trailing slashes, http vs https, www vs apex. Pick any URL detail and find the author who typed it the other way.
A text field for "link to a page" is a database without foreign keys. It will betray you the moment your content team does normal work.
The right model: links as references
Stop storing URLs. Store a reference to the entry the author wants to link to, and compute the URL from the entry. The link field on every content type becomes:
- A reference to a
Pageentry (or whichever entry types are linkable). - An optional override label, so authors can say "link to this page, but call it 'Read the story'."
- An optional anchor, for in-page jumps.
The author no longer needs to know URLs. They search by page title, pick the entry, and the system guarantees the link will resolve as long as the page exists.
Parent/child relations as the source of truth
The other half of the fix is modeling navigation hierarchy in the CMS itself. In most setups, the URL of a page lives in three places: a slug field, an editor's memory, and a hand-maintained navigation menu. Three sources, no agreement.
The model that holds up:
- One field on the page: the slug segment for that page only (e.g.
spring-launch, not the full path). - A self-reference field: parent, pointing at the page above it in the tree.
- A computed full path, derived by walking from the page to the root and joining the slug segments.
- The navigation menu is generated from this tree, not maintained by hand.
When a page moves under a new parent, every URL beneath it updates. Every reference link to it continues to resolve, because references store entry IDs, not paths.
How I solved it in Contentful
The pattern below is the exact model I now use on every Contentful project. It is small. It is the difference between a CMS that ages well and one that decays into a graveyard of broken links.
Page content type
A single Page type covers every URL on the site, with a self-reference for hierarchy.
title: Symbol // required, indexed
slug: Symbol // required, segment only ("spring-launch")
parent: Link<Entry<Page>> // optional, self-reference
locale: Symbol // required (en, da, ...)
sections: Array<Link<Entry<Section>>>
seo: Link<Entry<Seo>>
// Validation:
// - slug is unique among pages with the same parent + locale
// - parent must be of type Page and same locale
// - cycle check: a page cannot be its own ancestorLink field used everywhere else
Every other content type that needs a link — CTAs, nav items, cards, rich-text inline links — uses the same PageLink shape. Never a URL string.
page: Link<Entry<Page>> // required
label: Symbol // optional override
anchor: Symbol // optional, e.g. "pricing"
rel: Symbol // optional, e.g. "nofollow"
// External links are a separate type (ExternalLink),
// so the model itself prevents typing a URL into a field
// that is supposed to be internal.Path resolver
A small server function turns any Page into its full URL by walking the parent chain. The result is cached per page and invalidated by Contentful webhooks when a page's slug or parent changes.
type PageRef = { sys: { id: string }; fields: { slug: string; parent?: PageRef; locale: string } }
export function resolvePath(page: PageRef): string {
const segments: string[] = []
let current: PageRef | undefined = page
while (current) {
segments.unshift(current.fields.slug)
current = current.fields.parent
}
const localePrefix = page.fields.locale === "en" ? "" : `/${page.fields.locale}`
return `${localePrefix}/${segments.join("/")}`.replace(/\/+/g, "/")
}What the editor sees
When an author drops a link, they search for the page by title in Contentful's reference picker. The CMS shows the resolved URL as a read-only preview. If the target page is unpublished or deleted, Contentful surfaces a broken-reference warning right in the editor — before the change ships, not eleven days later.
Renaming a slug, moving a page under a new parent, or unpublishing a page now updates every link automatically, because no link ever stored the URL in the first place.
Migration path from text-field links
You don't have to rebuild to adopt this. The migration is mechanical:
- Add the new
PageLinkreference type alongside the existing text URL field. Don't delete the old one yet. - Run a one-time script that walks every entry, parses the URL string, finds the matching
Pageby path, and writes the reference. Log unmatched URLs. - Triage the unmatched list. Most will be external links (move them to
ExternalLink) or links to pages that no longer exist (these were already broken — now you know). - Stand up a redirect table for the 404s you found. This is also CMS-managed: a
Redirecttype withfromstring andtoas aPageLink. - Hide the old text URL field in the editor. Remove it after one publish cycle.
This is not Contentful-specific
Want this kind of judgment on your project?
I read every email within one working day. Bring a project, a quote, or a system you're stuck on.
15 things every Contentful enterprise project gets wrong in the first 6 weeks
The 15 production gaps every enterprise Contentful + Next.js build hits in the first six weeks — and how to close each one without burning a sprint. A pre-kickoff checklist for technical leads on a Contentful enterprise starter.
REST + GraphQL hybrids for multi-locale CMS-driven sites
Why neither REST-only nor GraphQL-only is the right call for an enterprise multi-locale CMS site, and how to split by concern instead. Includes the circular-reference problem on full REST payloads, the bundle cost of GraphQL on the client, the decision matrix per call site, the unified fetcher, granular cache tags, the block-as-fragment pattern, locale fallback in one round trip, Live Preview survival, Server Actions for CMA writes, the Algolia Sync API exception, and the migration sequence.
CMS-driven analytics: stop paying developers for every tracking change
How to structure your CMS so marketers can add tracking events without developer intervention. Save tens of thousands in ongoing costs.