Content Modelling for a Design System: Components over Page Types

Most marketing sites start with one schema per page template and end up with a dozen near-identical models that no one dares to change. Modelling reusable blocks that mirror your front end components instead turns a page into an ordered list, and this post shows what that JSON looks like, how a renderer consumes it, and what it costs the people who have to edit it.


A marketing site usually starts with a handful of schemas: HomePage, ProductPage, PricingPage, CaseStudy. Each one has exactly the fields its template needs. Hero headline, hero image, three feature columns, a testimonial quote, a closing call to action. It is fast to build and easy for an editor to understand, because the form looks like the page.

Then the second product launches. The new page needs the same hero, the same feature columns, but a comparison table instead of the testimonial. You copy ProductPage into ProductPageV2. Six months later you have eleven page schemas, four slightly different definitions of a hero, and a design system where the Hero component has one implementation in code and four in the content model.

The mismatch is the real problem. Your front end already decomposes pages into components. Your content model does not. Every change to a shared component has to be replayed by hand across every schema that happens to inline it.

A page is an ordered list

The alternative is to model the components, not the pages. One schema per component type: Hero, FeatureGrid, Testimonial, ComparisonTable, CtaBanner. Each has the fields that component actually takes as props, and nothing else.

A page then shrinks to almost nothing: a slug, SEO metadata, and an ordered list of component instances.

{
  "slug": "platform",
  "seo": {
    "title": "Platform",
    "description": "What the platform does."
  },
  "blocks": [
    {
      "type": "hero",
      "headline": "Ship content faster",
      "subline": "One model, every channel.",
      "image": { "id": "a31f", "alt": "Abstract shapes" },
      "variant": "dark"
    },
    {
      "type": "featureGrid",
      "columns": 3,
      "features": [
        { "title": "Schemas", "body": "Define fields once." },
        { "title": "API", "body": "REST and GraphQL." },
        { "title": "Workflows", "body": "Review before publish." }
      ]
    },
    {
      "type": "ctaBanner",
      "text": "Read the documentation",
      "href": "/docs"
    }
  ]
}

The shape of each entry in blocks is the prop interface of a component. That is the whole idea. If Hero gains a variant prop in code, the Hero schema gains a variant field, once, and every page that uses a hero inherits it.

How you store this depends on your CMS. Some systems give you a polymorphic list field that holds inline objects of different types. Others model each component as its own content item and let the page hold an ordered list of references. Squidex supports both references between schemas and nested array fields, so you can pick per component: inline for blocks that only ever live on one page, references for blocks that are genuinely shared, such as a footer CTA used site-wide.

The renderer is a switch statement

On the front end, the payoff is that rendering becomes mechanical. You map a type discriminator to a component and pass the rest through.

import { Hero } from "./Hero";
import { FeatureGrid } from "./FeatureGrid";
import { Testimonial } from "./Testimonial";
import { CtaBanner } from "./CtaBanner";

const registry = {
  hero: Hero,
  featureGrid: FeatureGrid,
  testimonial: Testimonial,
  ctaBanner: CtaBanner,
} as const;

type Block = { type: keyof typeof registry } & Record<string, unknown>;

export function Blocks({ blocks }: { blocks: Block[] }) {
  return (
    <>
      {blocks.map((block, i) => {
        const Component = registry[block.type];
        if (!Component) {
          if (process.env.NODE_ENV !== "production") {
            console.warn(`Unknown block type: ${block.type}`);
          }
          return null;
        }
        return <Component key={i} {...(block as any)} />;
      })}
    </>
  );
}

Two things to get right here. First, unknown types must not crash the page: an editor may publish a block from a newer deployment, or you may remove a component before the content is cleaned up. Skip and log. Second, generate your TypeScript types from the schema definitions rather than hand-writing them, so that a field rename in the model shows up as a compile error instead of an undefined in production.

A stable key matters too. The array index works until someone reorders blocks and React reuses state across different components. If your CMS gives each block instance an id, use it.

What this costs editors

Be honest about the trade-off, because it is real.

With page types, the editing form is a description of the page. Fill in the hero headline, fill in the three features, done. Nothing can be in the wrong order, nothing can be missing, nothing can be duplicated by accident.

With components, the editor gets an empty list and a menu of twenty block types. That is more freedom and more ways to produce something ugly: four heroes stacked on top of each other, a dark section next to another dark section, a page with no call to action. The design system constrains how each block looks, not how they combine.

Things that reduce the cost:

  • Name blocks for intent, not markup. Testimonial rather than QuoteWithImageRight. Editors pick by meaning; developers map meaning to markup.
  • Restrict the allowed set per page kind. A blog post does not need a pricing table. If your CMS lets you limit which schemas a reference or array field accepts, use it rather than relying on convention.
  • Keep component field counts small. If a block has nineteen fields, most of them are styling knobs that belong in the design system, not in content.
  • Invest in preview. Component modelling moves layout decisions to the editor, so the editor needs to see the result before publishing. A preview URL that renders draft content is not optional here; it is the thing that makes the model usable.
  • Provide starting points. A new page that begins as a copy of a reviewed example beats a new page that begins as an empty list.

Where page types still earn their keep

Do not convert everything. Content with a fixed, meaningful structure should stay structured: a job posting has a title, a location, a department and a description, and nothing good comes from letting someone assemble it out of blocks. The same goes for product records, events and author profiles.

The useful split is between content that is data and content that is a page. Data gets a tight schema. Pages get a block list. Often a page combines both: a structured CaseStudy entity plus an optional block list for the free-form section underneath.

A practical migration path is to leave existing page types alone and add a single blocks field to them. New sections go into the list, old fields stay until the template no longer reads them. You get the composability without a big-bang remodel.

If you are starting this now, the first step is not in the CMS at all: list the components your design system actually ships, with their props. That list is your content model, and most of the modelling work is deciding which props are content and which are design.