Page Builder
Adding a section
A section type is a contract spread across two repos: a Studio object schema (what editors fill in), an Astro *Section.astro component (what renders), and a few wiring points that keep the two in sync — miss one and the section silently vanishes from the page.
This page is the procedure. For the conceptual model — what a "section" is, how the page builder dispatches on _type, and the catalogue of types that already exist — read Page builder overview and Section types first. For how the scroll-in animate toggle actually moves pixels, see Scroll-in reveals.
The mental model
Editors compose pages from a menu of section types: they drop instances into a sections[] array on the homepage singleton, a page document, a service, a portfolioPost, or the index singletons' sectionsBelow[]. The website fetches that array and SectionRenderer.astro runs a switch (s._type) over it, dispatching each instance to its matching component.
For a new type to render, four pieces must agree on the same _type string (e.g. videoSection):
- Studio registry —
studio/schemaTypes/sections/registry.tslists the type as an allowed array member. - Studio schema — one
defineType({type: 'object', name: 'videoSection', …})describing the fields. - SectionRenderer — a
case 'videoSection':branch inwebsite/src/components/SectionRenderer.astro. - SectionData union — a
VideoSectionDatavariant inwebsite/src/sanity/types.ts, appended to theSectionDataunion.
Drift between these fails silently — the renderer switch just returns nothing and the section disappears with no error. A vitest guard (website/src/components/sectionTypes.test.ts) text-scans all three TypeScript-side artifacts and fails the build if they fall out of sync, so the cost of forgetting is caught at npm test rather than in production.
Silent failure is the footgun
A registered section with no SectionRenderer branch renders nothing — no console error, no fallback. Run the contract test (sectionTypes.test.ts) after wiring a new type; it lists exactly which of SectionRenderer.astro / types.ts is missing the type.
Add a brand-new section type
The canonical checklist also lives at the top of studio/CLAUDE.md under "Adding a brand-new section type". Worked example below: a videoSection.
1. Build the website component first
Create website/src/components/sections/VideoSection.astro with a Props interface for every field the section renders. Follow the standard section shape (see Components): wrap content in SectionShell.astro for the landmark / max-width / anchor offset / panel-vs-full-bleed surface, accept an anchor prop, and stegaClean() any Sanity string you branch on (string equality silently breaks in draft mode otherwise — see the warning below).
2. Mirror the props in a Studio schema
Create studio/schemaTypes/sections/video.ts. The schema name is camelCase + Section suffix (videoSection) — never dotted (section.video is invalid; Sanity requires JS-identifier names). Use defineType / defineField / defineArrayMember always. A minimal real schema looks like the FAQ section:
import {defineField, defineType} from 'sanity'
import {videoSectionDefaults} from '../../lib/section-defaults'
import {sectionLayoutField} from '../../lib/section-fields'
export const videoSection = defineType({
name: 'videoSection',
title: 'Video section',
type: 'object',
initialValue: () => ({...videoSectionDefaults}), // spread, never share one object
groups: [
{name: 'copy', title: 'Copy', default: true},
{name: 'styles', title: 'Styles'},
{name: 'meta', title: 'Meta'},
],
fields: [
// …your content fields (group: 'copy')…
sectionLayoutField(),
defineField({
name: 'animate',
title: 'Animate on scroll',
type: 'boolean',
group: 'styles',
initialValue: true,
}),
defineField({
name: 'hideSection',
title: 'Hide section',
type: 'boolean',
group: 'meta',
initialValue: false,
}),
defineField({name: 'anchor', title: 'URL anchor', type: 'string', group: 'meta'}),
],
preview: {
select: {hideSection: 'hideSection'},
prepare: ({hideSection}) => ({
title: 'Video',
subtitle: hideSection ? '· Hidden' : '',
}),
},
})
Group related fields once a schema has more than ~6 inputs (copy / styles / meta is the house convention). The preview.prepare appends · Hidden so editors can find dormant sections in the array.
3. Register the schema in the barrel
In studio/schemaTypes/sections/index.ts: import the schema and add it to the allSectionTypes array. That barrel re-exports allSectionTypes for document-type registration and re-exports sectionMembers from the registry.
import {videoSection} from './video'
export const allSectionTypes = [
// …existing…
videoSection,
]
4. Add it to the registry
In studio/schemaTypes/sections/registry.ts, add a defineArrayMember:
defineArrayMember({type: 'videoSection'}),
sectionMembers is the single source of truth for which section types exist. Both homepage.ts and page.ts import this one list as their sections.of, so a new entry auto-flows into every page-builder array — homepage, page, service, portfolioPost, and the index singletons' sectionsBelow[] — with no per-document edit.
5. Add the SectionRenderer dispatch branch
In website/src/components/SectionRenderer.astro, add a case to the switch (s._type), passing each prop through:
case 'videoSection':
return (
<VideoSection
anchor={s.anchor}
layout={s.sectionLayout}
{/* …your fields… */}
/>
);
Every branch already runs inside <AnimatedSection animate={s.animate}>, which renders the data-section-anim wrapper that arms the section's [data-reveal] units — you don't wire the animate toggle per-case, only pass s.sectionLayout through to SectionShell.
6. Add the SectionData union variant
In website/src/sanity/types.ts, define an interface that extends SectionBase and append it to the SectionData union:
export interface VideoSectionData extends SectionBase {
_type: 'videoSection';
// …your fields, all optional…
}
export type SectionData =
| HeroSectionData
// …existing…
| VideoSectionData;
SectionBase already carries the shared meta — _key, anchor?, hideSection?, animate?, sectionLayout? — so the variant only declares its own content fields. Keep every content field optional (?): Sanity is the single source of truth, and empty fields render nothing rather than a fallback string.
7. GROQ projection — only if the section dereferences documents
The website's sectionProjection (in website/src/sanity/queries/projections.ts) spreads … over each section, so plain fields, image refs, and inline objects flow through with no query change. You only add a _type == "videoSection" => {…} clause if the section holds references to other documents that need dereferencing — the way logoWallSection, testimonialsSection, and workSection deref clients[]-> / testimonials[]-> / caseStudies[]->. See Querying content for the projection mechanics.
8. Add defaults and seed (skip for reference-only sections)
Add a videoSectionDefaults constant to studio/lib/section-defaults.ts, register it in the sectionDefaultsByType map (keyed by _type, used by the seed script), and reference it from the schema's initialValue (step 2). Then backfill existing documents:
npx sanity exec scripts/seed-homepage-sections.ts --with-user-token
The seed is idempotent — it only fills empty fields, so it never stomps copy an editor has customized. section-defaults.ts is the single source of truth, shared by both the schema initialValue (new instances arrive pre-filled) and the seed script (existing docs get backfilled).
Meaningful placeholders, never empty strings
Every editor-visible string default gets real placeholder copy (eyebrow: 'Eyebrow text', heading: 'Section heading') so dropping a fresh section reveals every slot it offers. Never '', never omit. An empty-string default is worse than nothing: in draft preview Sanity stega-encodes strings with zero-width characters, so eyebrow: '' becomes a truthy string that renders an empty styled <span>. Skip initialValue only for reference-bearing sections, truly optional fields like anchor, and booleans the field-level initialValue already covers.
9. Wire the animate toggle (reveal units)
The animate boolean from step 2 is the entire editor surface for motion — reveal style, timing, stagger, and direction are all fixed in website code. Adding the field is half the job; in the component you also place data-reveal units on the elements that should cascade in. The placement rules are in the root README.md "Wiring a new section". Semantics to know:
- Unset = ON. The website checks
animate !== false, so the field-levelinitialValue: truecovers new instances and pre-existing data animates too. Editors only ever toggle OFF — nosection-defaults.tsentry needed foranimate. - Band-less sections skip the field entirely.
marqueeSectionhas noanimatebecause it has no outer band to reveal. - Heroes are special — they carry two booleans:
headingAnimation(the GSAP headline entrance, a separate system — see Hero animation) andanimate(the supporting copy/CTA cascade).
The shared "Display" lever (sectionLayout)
Most sections get the dark-redesign Display lever via the sectionLayoutField() factory in studio/lib/section-fields.ts — spread it into a panel-eligible schema's fields[]:
import {sectionLayoutField} from '../../lib/section-fields'
// …
fields: [ /* … */ sectionLayoutField(), /* … */ ]
It adds a sectionLayout object (mode + background): Panel (default) floats the section as a rounded card on the grid; Full bleed makes it an edge-to-edge band whose background is a chosen color or Transparent. Auto uses the section's built-in default surface (dark for most, cream for About) and is identical to leaving it blank.
Skip the lever for the always-full-bleed sections that ignore it: both heroes, marqueeSection, ctaSection, servicesExplorerSection, and stickyScrollSection. On the website side it's consumed by SectionShell.astro, carried through the GROQ … spread (no projection change), and mode / background are stega-cleaned before use.
stegaClean before any string comparison
In draft mode every Sanity string carries trailing zero-width characters, so a bare value === 'right' or mode === 'fullBleed' check silently fails. Call stegaClean() on any Sanity string field before using it as a logic key — sectionLayout.mode, sectionLayout.background, imagePosition, scoresPosition, revealTrigger, and friends all need it. The renderer passes the raw value through; the cleaning happens at the consuming component (SectionShell, the section's own frontmatter).
Add a field to an existing section
The short recipe — three steps, no contract dance. Example: an align: 'left' | 'center' | 'right' toggle on Hero.
- Studio: add
defineField({name: 'align', type: 'string', options: {list: [...]}})tostudio/schemaTypes/sections/hero.ts(and a meaningfulinitialValue/section-defaults.tsentry if it's content-bearing). - Website: add
align?to thePropsinterface inHeroSection.astro, add it toHeroSectionDataintypes.ts, and pass it through inSectionRenderer.astro'scase 'heroSection':branch. - GROQ: nothing — the
…spread insectionProjectionpicks up the new field automatically. (A projection clause is only needed for new reference fields that must be dereferenced.)
urlFor throws on asset-less images
If your new field is an image rendered through the image-URL builder, guard it — calling urlFor(image) on a value with no asset (an editor added the field but never uploaded) throws and takes down the whole page render. Gate with {image?.asset && …} (mirror the existing image-bearing sections).
Pre-flight gotchas
- Never delete a Sanity singleton to "reset" it.
npx sanity documents delete homepagetombstones the ID andinitialValuecan't recover it — recovery isclient.createOrReplace(...)only. This bites when you're iterating on thehomepagesingleton'ssections[]during section work. - Don't run
astro buildwhileastro devis running. The build clobbers the dev server's Vite dependency cache (it 404s the dev server'sgsapdep and breaks all client scripts). Useastro checkto type-check safely while dev is up. - No hardcoded copy fallbacks in the
.astrocomponent. Never add a?? '…'default — supply copy viasection-defaults.ts+ seed instead. And coerce array props to[]defensively: Sanity returnsnull(notundefined) for missing array fields, and JS default parameters only catchundefined. - Code-block / embed / CTA-form sections render pasted HTML as-is (including
<script>). Only paste embeds from trusted sources — there's no sanitization layer.
Where this lives
| Piece | File |
|---|---|
| Section registry (source of truth for which types exist) | studio/schemaTypes/sections/registry.ts |
Schema barrel (allSectionTypes) | studio/schemaTypes/sections/index.ts |
| Per-section schemas | studio/schemaTypes/sections/<type>.ts |
| Shared Display-lever factory | studio/lib/section-fields.ts |
Defaults + sectionDefaultsByType map | studio/lib/section-defaults.ts |
| Seed script (idempotent backfill) | studio/scripts/seed-homepage-sections.ts |
Renderer dispatch (switch (s._type)) | website/src/components/SectionRenderer.astro |
| Section components | website/src/components/sections/<Type>Section.astro |
SectionData union + SectionBase + SectionLayout | website/src/sanity/types.ts |
GROQ sectionProjection (deref clauses) | website/src/sanity/queries/projections.ts |
| Three-way contract guard | website/src/components/sectionTypes.test.ts |
| The shell (landmark / surface / stegaClean) | website/src/components/ui/SectionShell.astro |
| The animate wrapper | website/src/components/AnimatedSection.astro |
The authoritative narrative checklists are in studio/CLAUDE.md ("Adding a brand-new section type", "Per-section animations", "Defaults") and website/CLAUDE.md ("Page-builder rendering flow"). Related pages: Page builder overview, Section types, Content queries (GROQ), Scroll-in reveals, Visual editing.