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.
defineField({
name: 'sections',
title: 'Sections',
type: 'pageBuilder', // a named array type, so a second document type can reuse it
})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
- Name a section for what it does, not what it looks like.
compatibilitySection, nottableSection. A section named for its appearance will be reused for the wrong content within a month. - Enumerate presentation choices.
layout,columns,mediaPositionandtoneare 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. - 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.
- 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:
{ "_type": "featureListSection", "heading": "The highlights", "mode": "highlighted" }and the frontend resolves it when the page renders:
*[_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:
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} />
}
})
}