Content modelling

The page-builder pattern

Compose pages from section objects the frontend can render, instead of from free-form rich text. The pattern most real Sanity projects use, and the rules that keep it working.

A page document in a real Sanity project rarely has a single body. It has an array of section objects, and the frontend maps each member's _type to a component. Editors compose a page out of the sections the design system actually has.

schema/documents/page.tsts
defineField({
  name: 'sections',
  title: 'Sections',
  type: 'pageBuilder',   // a named array type, so a second document type can reuse it
})
schema/sections/index.tsts
export const pageBuilder = defineType({
  name: 'pageBuilder',
  type: 'array',
  of: [
    defineArrayMember({ type: 'heroSection' }),
    defineArrayMember({ type: 'featureGridSection' }),
    defineArrayMember({ type: 'splitSection' }),
    // …fourteen in this example schema
  ],
})

Four rules that keep it usable

  1. Name a section for what it does, not what it looks like. compatibilitySection, not tableSection. A section named for its appearance will be reused for the wrong content within a month.
  2. Enumerate presentation choices. layout, columns, mediaPosition and tone are closed lists of two or three values. That is the difference between a variant an editor can pick and a stylesheet an editor can break.
  3. Rich text only where prose belongs. A feature card's body is a length-limited string; a prose column is Portable Text. A rich-text field inside a grid cell produces layouts nobody designed.
  4. Let sections query. This is the half that a big rich-text field can never do.

Why enumerating variants matters

A closed list of two or three values is a variant an editor can pick. A free-form string is a stylesheet an editor can break, and it is also unqueryable: "how many pages use the split layout?" has an answer in the first case and a grep in the second.

Sections that query

A section can hold intent rather than content. The homepage's highlight strip stores one word:

json
{ "_type": "featureListSection", "heading": "The highlights", "mode": "highlighted" }

and the frontend resolves it when the page renders:

groq
*[_type == "feature" && highlight == true] | order(area asc, order asc)

Publishing a new feature therefore updates the homepage, with nobody re-curating a list. The same technique drives the documentation index and the latest-posts strip on this site.

Rendering it

One dispatch, and a deliberate default for the unknown case:

app/_site/sections.tsxtsx
export function PageSections({ sections }: { sections: readonly Section[] }) {
  return sections.map((section) => {
    switch (section._type) {
      case 'heroSection':
        return <Hero key={section._key} section={section} />
      case 'featureGridSection':
        return <FeatureGrid key={section._key} section={section} />
      default:
        // Shown, not dropped: a section the frontend cannot draw is a deployment
        // problem, and silence is the failure mode that survives review.
        return <UnknownSection key={section._key} type={section._type} />
    }
  })
}