Page Builder
Page builder overview
The page builder lets editors compose pages from a menu of section types: every page-builder document carries an ordered sections[] array, and the website renders whatever is in it, in order, through one polymorphic dispatcher.
Editors drop section instances into the array, fill fields, reorder, and remove — no code change. The single rule a maintainer must internalize is the three-way type contract: a section type only works end-to-end when it exists in the Studio registry, has a dispatch branch in SectionRenderer.astro, and has a variant in the SectionData union. This page covers the model; the section catalog lists each type, and adding a section is the step-by-step checklist.
The mental model
A page-builder document holds a flat array of polymorphic objects. Each object has a discriminating _type (e.g. heroSection, ctaSection, workSection) plus that section's own fields. The website fetches the array and maps each entry to its Astro component by switching on _type. There is no per-page layout code — the order of the array is the page.
Which documents carry section arrays:
| Document | Field | Route |
|---|---|---|
homepage (singleton) | sections[] | / |
page | sections[] | /<slug> via [slug].astro |
service | sections[] | /services/<slug> |
portfolioPost | sections[] | /portfolio/<slug> |
blogIndex (singleton) | sectionsBelow[] | /blog (below the post grid) |
portfolioIndex (singleton) | sectionsBelow[] | /portfolio (below the grid) |
All six share the same sectionProjection GROQ fragment and the same <SectionRenderer /> dispatch. The index singletons name their array sectionsBelow[] (not sections[]) because the post grid is the page's primary content and the builder sections render beneath it — but the shape and rendering are identical.
How a page renders
Every page-builder route follows the same fetch-then-dispatch shape:
// 1. Fetch — getHomepage() for /, getPageBySlug('services') for /services,
// getBlogIndex() for /blog, getPortfolioPostBySlug(slug) for /portfolio/<slug>
const data = await getPageBySlug('services')
// 2. Render — SectionRenderer takes the sections[] and dispatches on _type.
<SectionRenderer
sections={data.page?.sections}
clients={data.clients} // fallback when a logoWallSection has no manual picks
testimonials={data.testimonials} // fallback when a testimonialsSection has no manual picks
caseStudies={data.caseStudies} // fallback when a workSection has no manual picks
/>
SectionRenderer.astro (website/src/components/SectionRenderer.astro) imports every *Section.astro component, coerces its sections prop to an array defensively, then maps each entry through a switch (s._type):
const {sections, clients, testimonials, caseStudies} = Astro.props;
const list = Array.isArray(sections) ? sections : [];
---
{list.map((s) => {
return (
<AnimatedSection animate={s.animate}>
{(() => {
switch (s._type) {
case 'ctaSection':
return <CtaSection anchor={s.anchor} heading={s.heading} /* … */ />;
case 'workSection':
return <WorkSection /* … */ caseStudies={s.caseStudies?.length ? s.caseStudies : caseStudies} />;
// … one case per registered section type
}
})()}
</AnimatedSection>
);
})}
Each case reads fields off the typed s and forwards them as props. The s.sectionLayout field is passed through as a layout prop on the panel-eligible sections (it drives the panel-vs-full-bleed lever — see Design tokens & theme).
Drift fails silently
If a section type is registered in the Studio but has no case branch in SectionRenderer.astro, the switch simply returns nothing — the section vanishes from the rendered page with no error, no warning, nothing in the console. The same is true in reverse for a stale union variant. This is why the three-way contract below is guarded by a test, not left to discipline.
The three-way contract
A section type is real only when it exists in all three places, with matching _type string literals:
- Studio registry —
defineArrayMember({type: '…Section'})instudio/schemaTypes/sections/registry.ts. ThissectionMembersarray is the single source of truth for which section types exist; bothhomepage.tsandpage.tsimport it as theirsections.of. - SectionRenderer dispatch — a
case '…Section':branch inSectionRenderer.astro. - SectionData union — a variant interface (
_type: '…Section') appended to theSectionDatadiscriminated union inwebsite/src/sanity/types.ts.
These three are kept in sync by website/src/components/sectionTypes.test.ts. It reads all three files as text (the .astro file can't be imported in a Node test, and a literal scan is exactly the contract — the switch dispatches on string literals) and extracts the …Section identifiers from each:
const registryTypes = extract(registrySrc, /type:\s*'([a-z][A-Za-z]*Section)'/g);
const rendererLiterals = extract(rendererSrc, /'([a-z][A-Za-z]*Section)'/g);
const unionTypes = extract(typesSrc, /_type:\s*'([a-z][A-Za-z]*Section)'/g);
It then asserts four things: the registry parses (≥15 types), every registered type has a renderer branch, every registered type has a union variant, and there are no stale union variants for unregistered types. A failing assertion names the missing identifiers, e.g. "add a branch in SectionRenderer.astro for: videoSection".
Run the test after touching any of the three
The guard is the safety net that catches the silent-drift footgun. Adding a brand-new section type touches all three files (plus the schema and component); the test is what tells you if you forgot one. There are 21 section types registered today.
Reference-fallback threading
Three section types hold arrays of document references rather than inline content: logoWallSection (→ client), testimonialsSection (→ testimonial), and workSection (→ caseStudy). Each can be left empty by the editor, in which case the site auto-falls-back to a sensible default set.
This works in two layers. First, the references are dereferenced inside sectionProjection (website/src/sanity/queries/projections.ts) with _type == clauses that override the spread for just those types:
...,
_type == "logoWallSection" => {
...,
"clients": clients[]->{_id, name, logo, url, industry, order}
},
_type == "workSection" => {
...,
"caseStudies": caseStudies[]->{
_id, title, slug, industry, summary, headlineStat, cardColorway, coverImage,
"client": client->{name, logo, url}
}
},
The leading ... spread carries every other field unchanged; the _type == "X" => {…} clause only overrides the reference fields for matching sections. (Image refs and inline objects need no projection clause — only document references do.)
Second, the route fetches a fallback set alongside the page and passes it into <SectionRenderer /> as clients / testimonials / caseStudies. In the renderer, each reference section picks its own picks when present, else the fallback:
clients={s.clients?.length ? s.clients : clients}
The fallback sets come from featuredClientsProjection, featuredTestimonialsProjection, and recentCaseStudiesProjection (same file). So featured: true on client and testimonial documents is load-bearing — it controls the no-pick fallback. caseStudies falls back to the most recent case studies instead. On a service detail page, service.relatedCaseStudies is threaded in as the caseStudies fallback, so a workSection on a service page defaults to that service's own picks.
Sanity returns null for empty arrays
A missing array field comes back as null, not undefined — and JS default parameters only catch undefined. Reference sections guard with s.clients?.length ? … : fallback, and WorkSection coerces caseStudies to [] and filter(Boolean)s out deleted refs so a dangling reference doesn't throw. When you read any array prop from Sanity, coerce it defensively rather than relying on a default parameter.
The explorer sections (servicesExplorerSection, heroExplorerSection) and serviceGridSection use the same projection-fallback idea on their own data: their projections dereference the editor's services picks or fall back to all explorerService / service docs ordered by order. See section types for the per-type detail.
The two universal controls
Beyond its own fields, every section schema carries two controls that the page builder threads uniformly. They are not per-type — they exist on all sections.
hideSection — filtered at fetch time
hideSection is a boolean on every section schema. It is filtered out in GROQ, not in component code: every page-builder query selects sections[hideSection != true].
sections[hideSection != true]{${sectionProjection}}
This appears in all six queries — homepage.ts, page.ts, services.ts, portfolioPost.ts, and the sectionsBelow of blogIndex.ts / portfolioIndex.ts. A hidden section never reaches the wire, never reaches the renderer, and generates no markup — there is nothing to handle in component code. (Studio appends · Hidden to the array-item subtitle so editors can still find dormant sections.) It is a true removal, not a CSS display: none.
animate — the scroll-in wrapper
animate is the Studio "Animate on scroll" boolean. In the renderer, every section is wrapped uniformly in <AnimatedSection animate={s.animate}> — there are no short-circuit branches. AnimatedSection.astro (website/src/components/AnimatedSection.astro) renders a data-section-anim wrapper that arms the [data-reveal] units each section marks in its own markup:
{animate !== false ? (
<div data-section-anim>
<slot />
</div>
) : (
<slot />
)}
Two semantics matter here:
- Undefined means ON. The check is
animate !== false, so sections created before the field existed still animate; editors only ever toggle it OFF. - Presence, not value. The wrapper attribute is rendered by presence — never
data-section-anim={false}, which Astro would emit as the literal string"false"that the CSS presence selector still matches. Booleans carry no stega payload, so nostegaClean()is needed here.
A section with no [data-reveal] units (e.g. marqueeSection) gets the wrapper anyway, but it is naturally inert. The reveal field travels on the GROQ ... spread — no projection change is ever needed for it. The full cascade runtime is documented in scroll-in reveals.
Stega breaks string equality
hideSection and animate are booleans and carry no stega. But many section fields are strings used in logic — imagePosition, revealTrigger, variant, sectionLayout.mode/background. In draft preview those strings carry invisible Unicode tag characters, so a raw value === 'right' silently fails. Always stegaClean() a Sanity string before any equality check, switch, regex, or index lookup. Rendering a plain string needs no cleaning. See visual editing.
Empty fields render nothing
Sanity is the single source of truth for copy. Section components render their fields directly; when a field is empty, the corresponding markup simply does not render — there are no hardcoded fallback strings or FALLBACK_* arrays in the .astro components. Default copy is supplied editor-side via initialValue + the seed scripts (studio/lib/section-defaults.ts, studio/scripts/seed-homepage-sections.ts), so a freshly dropped section shows meaningful placeholders in Studio without leaking hardcoded copy to production. When you add a content field, mirror its default into section-defaults.ts and re-seed — never add a ?? '...' fallback in the component. See Studio & singletons for the defaults model.
Where this lives
| Concern | File |
|---|---|
Dispatch (switch (_type)) | website/src/components/SectionRenderer.astro |
| Scroll-in wrapper | website/src/components/AnimatedSection.astro |
SectionData union + SectionBase | website/src/sanity/types.ts |
| Three-way contract test | website/src/components/sectionTypes.test.ts |
| Studio section registry | studio/schemaTypes/sections/registry.ts |
| Reference dereffing + fallbacks | website/src/sanity/queries/projections.ts |
hideSection GROQ filter | homepage.ts, page.ts, services.ts, portfolioPost.ts, blogIndex.ts, portfolioIndex.ts (all in website/src/sanity/queries/) |
| Section components (one per type) | website/src/components/sections/*Section.astro |
| Section schemas (one per type) | studio/schemaTypes/sections/*.ts |
| Editor-facing pattern docs | website/CLAUDE.md ("Page-builder rendering flow"), studio/CLAUDE.md ("Page-builder pattern") |
Next: the section type catalog and the add-a-section checklist.