SCA Headless CMS The build

Case Study · Architecture · Springfield Commonwealth Academy

The Build

01Stack

The stack

Each piece is here for what it does. Astro lets one project run prerendered index pages and SSR content pages — no two-app monorepo, no Studio in a separate repo.

  • Astro 5

    Framework

  • Sanity v3

    CMS

  • Vercel

    Hosting

  • GitHub Actions

    CI

  • Playwright

    Testing

  • axe-core

    Accessibility

The rest of the stack is enforcement. Anything that doesn't pass typecheck, axe-core, Playwright, and Lighthouse doesn't ship.

The setup is unremarkable. Running it consistently — not skipping checks because they feel slow on a small change — is the part I pay attention to.

02Schema

The schema and the page builder

The eleven blocks: heroSection, textWithImage, cardGrid, ctaBanner, richText, statsRow, testimonialBlock, accordionSection, upcomingEvents, latestNews, videoEmbed. Each is its own schema, its own renderer, its own sample. The block library renders them with live data so an editor can see what they look like before placing them on a page.

The most recent of the eleven was videoEmbed (ADR-027), which renders YouTube and Vimeo URLs in privacy mode. Adding it was a single PR — schema, renderer, sample, test. Adding a twelfth would be the same shape of work.

Whether to add a twelfth is the harder question than how. Every block in the menu increases the cognitive load on editors. Removing a block already in use means rewriting the pages that use it.

defineField({
  name: 'kind',
  title: 'Kind',
  type: 'string',
  options: {
    list: [
      { title: 'Brief', value: 'brief' },
      { title: 'Essay', value: 'essay' },
      { title: 'Case Study', value: 'case-study' },
    ],
    layout: 'radio',
  },
  validation: (rule) => rule.required(),
})

Typed options. Declared validation. No string primitives where an enum belongs. The schema is meant to be read by editors, agents, and the people who inherit the platform — in roughly that order.

03State machine

A state machine for student projects

Student projects are different. They need a Google Drive folder provisioned, access granted by student email, and the existence of that folder tracked back into the CMS. So the document type carries a four-stage state field: Pending → Provisioning → Complete → Error.

A long-lived watcher polls Sanity every ten seconds for documents in Pending or stale Provisioning. When it finds one, it claims the document, runs GAM (Google Apps Manager) commands to create or share the Drive folder, and writes the result back to Sanity.

Idempotency is what keeps a crashed worker from creating duplicate Drive folders. The folder name embeds the Sanity document _id as a key.

The watcher's first call to GAM looks up the folder by that key before creating one. A crashed worker, a redeployed agent, or a half-completed run finds the existing folder and continues from there.

Editor-owned and agent-owned fields share the document. Editor-facing fields (title, summary, gallery) are mutable in the Studio. Agent-facing fields (status, idempotencyKey, provisioning timestamps, raw GAM responses) are readOnly in the Studio definition. Editors can see agent state for debugging; they cannot break the state machine by editing it.

Polling, with webhooks on the deferred list. A Sanity publish webhook would give sub-second provisioning latency instead of the 0–10 second window the polling worker has.

The plan is to add the webhook as a fast path and keep the poller as crash recovery. Webhooks miss occasionally — network blips, replay drops, queue eviction. The poller catches what the webhook drops.

Most of the webhook code is already in the poller — claim, lock, run, write back. The trigger swaps in; the rest stays.

The public showcase route is currently dormant per ADR-019. Reactivation gates on consent, content review, and the curated dataset reaching showcase quality. The architecture is what's being evaluated, not the URL.

04GROQ

GROQ in practice

The query for the public student-projects listing, when the route is enabled:

*[_type == "studentProject"
    && visibility == "Public"
    && status == "Complete"]
  | order(_createdAt desc) {
    _id,
    title,
    "slug": slug.current,
    summary,
    "image": featuredImage.asset->url,
    year,
    externalUrl
  }

Everything fetched is what the card needs. Nothing else. The provisioningData object — folder ID, error messages, raw responses — lives on the document but isn't in the projection. Editor-owned and agent-owned fields share a document; the public projection only includes what the public should see.

The leak surface narrows by what you ask for, not by what you forbid.

05Migration

The Webflow migration

The school's site lived on a Webflow build before this. Migration meant extracting content from rendered HTML, converting prose to Portable Text, rehosting images as Sanity assets, and rebuilding navigation as document references rather than hand-coded links.

Each piece moved through a custom extraction script, a Portable Text conversion pass, and a manual review before going into the dataset. The news archive came across in the same pass. The legacy webflow.io staging snapshot is preserved as a reference for anyone who wants to compare.

Migration ran beyond editorial content. Basketball and soccer alumni rosters — names, class years, achievements — moved into Sanity as structured documents queryable by athlete, year, and program.

A school year of events from a spreadsheet column became Sanity event documents queryable by date and category, imported by a script that ran once and stayed in version control.

The schemas are unglamorous. An events table I can sort, filter, and reuse for next year's calendar is a different asset from an HTML page that happens to list the same dates.

06References

The reference pages

Reference pages live at /admin/* on the public site — unindexed but fully reachable. The audience is editors and developers who inherit the platform, not search engines.

  • Platform overview — what the platform contains, written for stakeholders, not engineers.
  • Block library — every page-builder block with sample data, schema file path, and renderer file path. An editor finds a block here and knows what it does. A developer finds the same block here and knows where to edit it.
  • Reskinning guide — three layers (chrome, design tokens, per-block CSS) with worked examples and a live theme toggle.