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):

  1. Studio registrystudio/schemaTypes/sections/registry.ts lists the type as an allowed array member.
  2. Studio schema — one defineType({type: 'object', name: 'videoSection', …}) describing the fields.
  3. SectionRenderer — a case 'videoSection': branch in website/src/components/SectionRenderer.astro.
  4. SectionData union — a VideoSectionData variant in website/src/sanity/types.ts, appended to the SectionData union.

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-level initialValue: true covers new instances and pre-existing data animates too. Editors only ever toggle OFF — no section-defaults.ts entry needed for animate.
  • Band-less sections skip the field entirely. marqueeSection has no animate because 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) and animate (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.

  1. Studio: add defineField({name: 'align', type: 'string', options: {list: [...]}}) to studio/schemaTypes/sections/hero.ts (and a meaningful initialValue / section-defaults.ts entry if it's content-bearing).
  2. Website: add align? to the Props interface in HeroSection.astro, add it to HeroSectionData in types.ts, and pass it through in SectionRenderer.astro's case 'heroSection': branch.
  3. GROQ: nothing — the spread in sectionProjection picks 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 homepage tombstones the ID and initialValue can't recover it — recovery is client.createOrReplace(...) only. This bites when you're iterating on the homepage singleton's sections[] during section work.
  • Don't run astro build while astro dev is running. The build clobbers the dev server's Vite dependency cache (it 404s the dev server's gsap dep and breaks all client scripts). Use astro check to type-check safely while dev is up.
  • No hardcoded copy fallbacks in the .astro component. Never add a ?? '…' default — supply copy via section-defaults.ts + seed instead. And coerce array props to [] defensively: Sanity returns null (not undefined) for missing array fields, and JS default parameters only catch undefined.
  • 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

PieceFile
Section registry (source of truth for which types exist)studio/schemaTypes/sections/registry.ts
Schema barrel (allSectionTypes)studio/schemaTypes/sections/index.ts
Per-section schemasstudio/schemaTypes/sections/<type>.ts
Shared Display-lever factorystudio/lib/section-fields.ts
Defaults + sectionDefaultsByType mapstudio/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 componentswebsite/src/components/sections/<Type>Section.astro
SectionData union + SectionBase + SectionLayoutwebsite/src/sanity/types.ts
GROQ sectionProjection (deref clauses)website/src/sanity/queries/projections.ts
Three-way contract guardwebsite/src/components/sectionTypes.test.ts
The shell (landmark / surface / stegaClean)website/src/components/ui/SectionShell.astro
The animate wrapperwebsite/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.

Previous
Section types