Architecture

Monorepo architecture

White Tree Digital is one repo holding two independent packages — website/ (the Astro marketing site that renders pages) and studio/ (the Sanity Studio where content is authored) — joined only by a shared content contract, not by code or a common Git history.

The mental model is a one-way pipe. studio/ defines the shape of content (schemas) and is where an editor types the content (documents). website/ reads that content at build/request time and renders it. The website never writes back. Each package deploys to a different host on its own cadence, so the only thing keeping them in sync is a discipline you enforce by hand: when a field's shape changes in studio/, the matching TypeScript type and renderer branch in website/ must change too.

The two packages

PackageWhat it isDeploys toHow it deploys
website/Astro 6 + Tailwind v4 marketing site, React 19 islands, SSR on Cloudflare Workers via @astrojs/cloudflare. Reads content from Sanity.Cloudflare Workers (https://whitetreedigital.emanuelgold.workers.dev; canonical whitetreedigital.com)Git push to main
studio/Sanity v5 Studio — schemas, page-builder sections, Presentation Tool, seed/migration scripts. Project ID 8ftz0iuv, single dataset production.https://whitetreedigital.sanity.studionpx sanity deploy from studio/

Root-level files (CLAUDE.md, the _*.md runbooks, this docs site) sit above both and document the seam.

Two independent repos, separate Git histories

website/ and studio/ don't share a Git history and deploy by completely different mechanisms — a website code push never touches the Studio, and npx sanity deploy never touches the site. Don't reason about them as one buildable unit; a change that spans both is two commits in two trees.

The root CLAUDE.md states the rule plainly: read the relevant child CLAUDE.md (website/CLAUDE.md or studio/CLAUDE.md) before touching either package.


The website src/ tree

website/src/ is organized by role. The annotated shape (from website/CLAUDE.md):

src/
  components/
    layout/              Header, Footer (data-driven from siteSettings)
    ui/                  atoms + composites: Button, Pill, Stat, SectionHead, Marquee, WorkCard, FauxBrowser
    cards/               TestimonialCard, PricingTier, FaqItem, ProcessStep, ServiceCard, PostCard
    sections/            page-section composites (HeroSection, WorkSection, RichTextSection, …)
    blog/                BlogPost, AuthorByline, RelatedPosts — used by /blog/[slug]
    portfolio/           PortfolioPost — used by /portfolio/[slug]
    audit/               the /free-website-audit lead-magnet tool (checks, scoring, teaser)
    SectionRenderer.astro  polymorphic dispatch — takes a sections[] array, renders each via switch(_type)
    PostGrid.astro       shared blog + portfolio grid; gridVariant: standard | featured | mosaic
  layouts/               Layout.astro — must import ../styles/global.css
  middleware.ts          draft-cookie signature gate + edge cache (Workers Cache API, 5-min TTL)
  pages/                 routes (kebab-case filenames) — incl. dynamic sitemap.xml.ts, robots.txt.ts, 404.astro
  lib/                   sanity client, image-url builder, schema.org builders, portableText serializer
  sanity/
    queries/             GROQ per content type (homepage, page, services, blogIndex, …) + shared projections.ts
    types.ts             hand-written types incl. SectionData discriminated union
  styles/                global.css — @theme tokens + @font-face

A few load-bearing roles:

  • layouts/Layout.astro is the single place global styles load — it must keep import '../styles/global.css' in its frontmatter.
  • middleware.ts verifies the draft-preview cookie signature and runs the Workers edge cache (draft requests bypass; inert in dev and on workers.dev).
  • components/SectionRenderer.astro is the dispatch point for the page builder — covered below.
  • sanity/ holds everything Sanity-facing on the read side: GROQ queries, shared projection fragments, and the hand-written types.ts.

The folders that own their own subsystems — audit/, blog/, portfolio/ — are documented on their dedicated pages (Audit tool, Blog & portfolio). For the route files themselves see Routing & pages; for visual styling see Brand tokens and Components.


Studio anatomy

studio/schemaTypes/ is a file-per-schema tree. Document types (homepage.ts, page.ts, post.ts, service.ts, siteSettings.ts, …) live at the top level; the page-builder section objects live under schemaTypes/sections/.

The defining pattern is one object schema per Astro section component. Each file in schemaTypes/sections/ is a defineType({type: 'object', name: 'heroSection', …}) that mirrors the props of exactly one *Section.astro in the website. Today there are 21 of them.

import {heroSectionDefaults} from '../../lib/section-defaults'

export const heroSection = defineType({
  name: 'heroSection',
  type: 'object',
  initialValue: () => ({...heroSectionDefaults}),
  fields: [ /* … */ ],
})

Two files tie the section schemas together:

  • schemaTypes/sections/registry.ts exports sectionMembers — a flat defineArrayMember[] list. Both homepage.ts and page.ts import this one list as their sections.of. It is the single source of truth for which section types exist in the page builder.
  • schemaTypes/sections/index.ts is the barrel — it exports allSectionTypes (the array of section schemas) for registration in schemaTypes/index.ts, and re-exports sectionMembers from the registry.
// studio/schemaTypes/sections/registry.ts
export const sectionMembers = [
  defineArrayMember({type: 'aboutSection'}),
  defineArrayMember({type: 'codeBlockSection'}),
  // … 21 entries total …
  defineArrayMember({type: 'workSection'}),
]

registry.ts is the gate — forget it and the section is invisible

A section schema can compile fine yet never appear in the page builder if it isn't listed in registry.ts. Adding to allSectionTypes registers the type; adding the defineArrayMember to sectionMembers is what makes editors able to drop it into a page. Both are required.

Schema names are camelCase with a Section suffix (heroSection, aboutSection) — never dotted (section.hero is invalid; Sanity requires JS-identifier names). The full document-type catalogue lives on Content model; the section list and how the page builder assembles them are on Section types and Page builder overview.


How a section flows across both packages

A single section type is wired across four places that must agree. Walking heroSection:

  1. Studio schemastudio/schemaTypes/sections/hero.ts declares the fields.

  2. RegistrydefineArrayMember({type: 'heroSection'}) in studio/schemaTypes/sections/registry.ts admits it to homepage.sections[] / page.sections[].

  3. Website typewebsite/src/sanity/types.ts declares a discriminated-union member keyed on _type, then adds it to the SectionData union:

    export interface HeroSectionData extends SectionBase {
      _type: 'heroSection';
      title?: string;
      supporting?: string;
      // …
    }
    
    export type SectionData =
      | HeroSectionData
      | AboutSectionData
      // … one variant per section type …
    
  4. Website rendererwebsite/src/components/SectionRenderer.astro is a switch (s._type) over that union, with one case 'heroSection': branch per type. Today it has 21 case branches, matching the 21 registry entries.

SectionBase carries the meta fields every section shares — _key, anchor?, hideSection?, animate?, sectionLayout? — so each variant only declares its own content fields plus its _type literal.

The schema ↔ type sync rule

This is the contract that keeps the two packages honest. From the root CLAUDE.md:

Schema changes in studio/ that affect rendered fields require matching updates in website/src/sanity/types.ts and SectionRenderer.astro.

Two cases:

  • Adding a field to an existing section — add the defineField in the Studio schema, add the optional property to that section's interface in types.ts, and thread it through the matching case branch in SectionRenderer.astro. The GROQ projection picks it up automatically via the spread, so no query change.
  • Adding a brand-new section type — touches both packages end to end (new *Section.astro + new schema + register in index.ts + add to registry.ts + a case branch + a SectionData variant). The full step-by-step is on Adding a section; the studio CLAUDE.md has the canonical checklist.

The sync is by hand — nothing checks it for you

types.ts is hand-written (typegen is a deferred "promote later"). A field added in Studio with no matching type/renderer change just silently doesn't render; a renamed field shows up as undefined. When you change a section's shape in studio/, update types.ts and SectionRenderer.astro in the same change, or the website renders stale.


The website-only-reads rule

website/ only ever reads from Sanity — never write to the dataset from website code. All authoring happens in studio/; all mutation tooling (seed scripts, migrations, the color-token init) lives in studio/scripts/ and runs under sanity exec, not in the site.

This is why the data direction is a one-way pipe and why the read path is centralized:

  • Published reads go through the sanity client in website/src/lib/sanity.ts; draft-aware reads go through sanityPreview. Route files pick the right one per request via getSanityClient(Astro.cookies) / getRouteClient in website/src/lib/sanity-draft.ts — never import the raw clients into a route.
  • Shared GROQ lives in website/src/sanity/queries/; the sectionProjection fragment in projections.ts is reused by every page-builder consumer.

Hidden sections are filtered out at fetch time, not in component code — every page-builder query uses sections[hideSection != true]{...}:

sections[hideSection != true]{${sectionProjection}}

That filter appears in homepage.ts, page.ts, services.ts, portfolioPost.ts, and (as sectionsBelow) blogIndex.ts + portfolioIndex.ts — so a hideSection: true section never reaches the wire or the renderer. Querying details are on Querying content (GROQ); the read clients and draft seam on Sanity mental model and Visual editing.


Separate deploys, separate cadences

The two packages ship independently, and the cadence difference is intentional:

  • studio/ redeploys with npx sanity deploy from the studio/ dir to whitetreedigital.sanity.studio. The production preview URL is baked in from studio/.env.production at deploy time.
  • website/ deploys to Cloudflare Workers from a Git push to main. The site builds two ways from the one repo — a static public build and an SSR staging build — selected by BUILD_TARGET. That split (and why Presentation targets staging, not public) is its own page: Two-build split. Deployment mechanics are on Deployment (Cloudflare Workers).

A consequence worth internalizing: publishing a document in Studio does not rebuild the public site. Content publishes are instant on staging (SSR reads live) but reach the static public site only on a manual deploy hook or a code push. Adding a brand-new root-level page doc is content-only — no website code change — but it still needs a public deploy to appear there.

Secrets live in the deploy host, never in the repo

The runtime secret SANITY_API_READ_TOKEN (and PSI_API_KEY for the audit tool) lives in .dev.vars locally and as an encrypted env var in the Cloudflare Workers dashboard — never in .env, never committed. Both are in .gitignore. Public build-time config (PUBLIC_*) is fine in .env. See Environment variables and Security.


Where this lives

ConcernFile / location
Monorepo intro + cross-package rulesCLAUDE.md (root)
Website stack, layout, conventionswebsite/CLAUDE.md
Studio facts, schema overview, page-builder patternstudio/CLAUDE.md
Annotated website source treewebsite/src/
Section dispatch (switch (s._type))website/src/components/SectionRenderer.astro
Section discriminated union (SectionData, SectionBase)website/src/sanity/types.ts
GROQ queries + shared sectionProjectionwebsite/src/sanity/queries/
Read clients + draft-aware pickerwebsite/src/lib/sanity.ts, website/src/lib/sanity-draft.ts
One object schema per sectionstudio/schemaTypes/sections/*.ts
Section type registry (single source of truth)studio/schemaTypes/sections/registry.ts
Section schema barrel (allSectionTypes)studio/schemaTypes/sections/index.ts
Document type schemasstudio/schemaTypes/*.ts
Mutation tooling (seed / migration / init)studio/scripts/
Previous
Environment variables