Your CMS is not the source of truth!!!!!!

A content model earns its keep by what it refuses to store, not by what it can hold. Notes on referential integrity, and the guarantees you give up the moment a reference becomes a string in a text column.

Cover for Your CMS is not the source of truth!!!!!!
Updated: Aug 4, 2026
TL;DR
  • A reference inside a string is invisible to the database.
  • You will reimplement population, validation and delete protection by hand.
  • That is a fine trade, but make it deliberately.

Every content model has a moment where it stops describing the world and starts describing the editor that produced it. Usually nobody notices until something needs to change.

Testing an Image

Lalalalalalal

The comfortable lie

A CMS presents content as documents. Underneath, what it actually stores is a graph: posts point at authors, authors point at avatars, posts point at categories. The document view is a convenience over that graph.

The moment you store a reference inside a text field rather than as a column, you take that edge out of the graph and hide it in a string. Everything the database used to do for you becomes your job.

What you give up

  • Population. Nothing resolves the reference at read time; you write that.
  • Existence. Nothing stops a reference pointing at a row that was deleted last Tuesday.
  • Reverse lookup. Which posts use this image? becomes a table scan instead of an index hit.
  • Delete protection. Nothing knows the row is still in use, so nothing refuses.

A concrete case

Suppose post bodies are stored as text, and images are referenced by id inside that text:

const MARKDOWN_IMAGE = /!\[([^\]]*)\]\(([^)\s]+)\)/g
const IMAGE_TOKEN = /^image:(\d+)$/

export function scanBody(body: string): number[] {
  const ids: number[] = []

  for (const [, , url] of body.matchAll(MARKDOWN_IMAGE)) {
    const token = IMAGE_TOKEN.exec(url)
    if (token) ids.push(Number(token[1]))
  }

  return ids
}

That function is small and it works. It is also the first of four things you now own, because every guarantee in the list above has to be rebuilt on top of it.

The tell is not that the code is bad. The tell is that you are writing code whose only job is to recover something the database was already willing to give you.

Counting the cost honestly

GuaranteeColumn referenceReference inside text
Populated on readfreehand-written hook
Existence checkedfreehand-written validation
Reverse lookupindexed queryfull scan
Delete protectionone guardone guard, over a scan

Three of the four rows are a straight loss. The fourth is a wash.

So why do it anyway

Because the string is portable and the graph is not. Text survives the CMS that produced it. It diffs, it greps, it can be hand-edited at three in the morning without booting an admin panel, and it does not encode a particular editor’s node schema into your archive.

That is a real benefit and it is worth real money. The mistake is not choosing it. The mistake is choosing it without noticing that you just signed up to write four things by hand, and then being surprised, six months later, that the system feels like it is fighting you.

What to actually do

  1. Decide which references genuinely need to be portable.
  2. For everything else, use a column and let the database do its job.
  3. Where you keep the string, write the four guarantees up front rather than discovering them one incident at a time.

The worst outcome is the hybrid you drift into by accident: half the edges in columns, half in strings, and no memory of which decision was deliberate.

Related Posts

The Starter

Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lectus interdum facilisis odio scelerisque vel neque.

Copyright 2026The Starter. All Rights Reserved