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:

DocumentFieldRoute
homepage (singleton)sections[]/
pagesections[]/<slug> via [slug].astro
servicesections[]/services/<slug>
portfolioPostsections[]/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:

  1. Studio registrydefineArrayMember({type: '…Section'}) in studio/schemaTypes/sections/registry.ts. This sectionMembers array is the single source of truth for which section types exist; both homepage.ts and page.ts import it as their sections.of.
  2. SectionRenderer dispatch — a case '…Section': branch in SectionRenderer.astro.
  3. SectionData union — a variant interface (_type: '…Section') appended to the SectionData discriminated union in website/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 no stegaClean() 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

ConcernFile
Dispatch (switch (_type))website/src/components/SectionRenderer.astro
Scroll-in wrapperwebsite/src/components/AnimatedSection.astro
SectionData union + SectionBasewebsite/src/sanity/types.ts
Three-way contract testwebsite/src/components/sectionTypes.test.ts
Studio section registrystudio/schemaTypes/sections/registry.ts
Reference dereffing + fallbackswebsite/src/sanity/queries/projections.ts
hideSection GROQ filterhomepage.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 docswebsite/CLAUDE.md ("Page-builder rendering flow"), studio/CLAUDE.md ("Page-builder pattern")

Next: the section type catalog and the add-a-section checklist.

Previous
Querying content (GROQ)