Skip to content
SJ
All writing
9 min read

Structured Content for a Real Storefront

A headless CMS fails when content is modelled as pages rather than data. Model entities, and every surface — web, app, email — composes them.

SanityCMSNext.jsArchitectureContent

The pitch for a headless CMS is that content is delivered as data rather than as rendered pages, so any surface can consume it. Most implementations then model the content as pages anyway, and inherit every limitation they were trying to escape.

The page trap

It starts reasonably. Marketing needs a landing page, so you create a Page type with a title, a hero image and a body. Then they need a different layout, so you add a page-builder array of blocks. Then a second landing page reuses the same three treatments, and someone copy-pastes them.

Six months later the same clinic address exists in nine pages, a phone number changes, and updating it is a search through a CMS. The mobile app cannot use any of it, because what is stored is a sequence of layout blocks rather than facts. The content is shaped like a web page, which means it is only usable by a web page.

The question that avoids this: is this a thing that exists in the business, or a way of displaying things? A treatment, a clinic, a practitioner, a product — those exist. A three-column feature strip is a display decision. The first kind gets a document type; the second is presentation.

Modelling entities, then composing them

Model the nouns, give them real fields, and let pages reference them:

treatment: {
  name, slug, summary, body,
  category      -> reference(category),
  practitioners -> array of reference(practitioner),
  offeredAt     -> array of reference(clinic),
}

clinic: { name, slug, address, geo, hours, phone }

The phone number now lives in one place. A page that lists clinics references them rather than restating them. The mobile app queries the same clinics. A store locator, a sitemap, structured data for search engines and an email footer are all projections of the same records, because the records describe reality rather than a layout.

References over duplication is the core discipline, and it has one real exception worth naming: content that must be historically accurate. Content is generally live, so the referenced value is the current one, which is what you want — but if a published article quoted a price, that quotation belongs in the article as a value, not as a reference that will silently update.

Composition still gets you flexible pages. A page holds an ordered list of sections, and a section may be a reference to a treatment plus a display variant. Editors arrange and choose presentation; they do not re-enter facts.

Rich text should not be HTML

Storing a body field as an HTML string is the most common irreversible mistake in a CMS.

HTML is presentation, and it arrives with whatever the editor's toolbar produced — inline styles, spans from a paste, markup that renders once and cannot be reused. It cannot be rendered natively in a mobile app, it must be sanitised on every render or it is an injection risk, and the moment your design changes you are rewriting stored content rather than a component.

A structured rich-text format stores an array of typed blocks — paragraphs, headings, marks, and custom types you define. Rendering is a mapping from block type to component, which means:

  • The same content renders as React, as native mobile views, or as plain text for an email or a search index.
  • Restyling headings is a component change, not a content migration.
  • Custom blocks can be real references — an inline product card that points at a product, not a snapshot of one.
  • No sanitisation problem, because there is no arbitrary HTML.

The cost is that every block type needs a renderer, and an unmapped type renders as nothing — silently. Handle the default case with something visible in development.

Drafts and preview without leaking

Editors need to see unpublished work in the real design, and the naive version of this leaks drafts to the public.

The shape that works: published content is fetched with a read-only, published-only credential — the one used for the public site, which literally cannot see drafts. Preview is a separate mode, entered through an authenticated route that sets a signed cookie, which switches fetching to a credential that can read drafts and disables caching for that session.

Two failure modes to avoid deliberately:

  • Using a draft-capable token for the public site and filtering drafts in application code. One missing filter in one query publishes unfinished content, and nothing will alert you.
  • Caching preview responses. A cached draft that gets served to a normal visitor is the same leak by a different route. Preview must be uncached and clearly marked in the UI, so editors know which mode they are in.

Publishing, and the freshness question

A statically generated site is fast and stale by definition, so the question is how quickly a publish becomes visible.

Rebuilding the whole site on every publish is simple and does not scale — a typo fix should not rebuild ten thousand pages. The workable approach is a webhook from the CMS that invalidates only what changed, which means knowing what a piece of content affects: the treatment page, the category listing, the homepage if it features that treatment, and the sitemap.

Tagging fetches by the content they read makes this tractable — invalidate by tag rather than by path, and the dependency is recorded where the data is used instead of in a mapping someone maintains separately.

A time-based revalidation window as a backstop is worth having regardless. Webhooks get missed, and a page that is at most a few minutes stale is a much better failure than one that is stale until the next deploy.

Who owns the model

The schema is code, in your repository, reviewed. That is correct — it is a contract the site depends on — and it creates the tension that defines working with a CMS: marketing wants a new field and it requires a developer.

Resisting that entirely leads to a schema so generic it is a page builder again. Conceding entirely leads to forty fields nobody can explain. The line that has held up for me: editors get freedom over arrangement and presentation, developers own the shape of facts. Adding a section to a page, reordering, choosing a variant, writing copy — all editorial. Introducing a new entity or a new field on an existing one is a schema change with a review.

Two things make that bearable rather than bureaucratic. Validation and helpful descriptions in the schema mean editors get immediate feedback rather than a broken page and a support message. And previewing content in the real design inside the editing interface removes most requests for new fields, because a surprising number of them are really requests to see what something will look like.

The short version

Model the things that exist in the business and let pages reference them, so a fact lives in one place and every surface can consume it. Store rich text as structured blocks rather than HTML, so rendering is a component mapping and restyling is not a migration. Keep the public credential incapable of reading drafts instead of filtering them in code. Invalidate by content tag on publish with a time-based backstop. And draw the ownership line at arrangement versus the shape of facts.

Written by Saumya Jain

Full Stack Engineer working on headless commerce, NestJS microservices, and real-time systems. Currently open to remote work.