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
| Package | What it is | Deploys to | How 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.studio | npx 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.astrois the single place global styles load — it must keepimport '../styles/global.css'in its frontmatter.middleware.tsverifies the draft-preview cookie signature and runs the Workers edge cache (draft requests bypass; inert in dev and onworkers.dev).components/SectionRenderer.astrois 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-writtentypes.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.tsexportssectionMembers— a flatdefineArrayMember[]list. Bothhomepage.tsandpage.tsimport this one list as theirsections.of. It is the single source of truth for which section types exist in the page builder.schemaTypes/sections/index.tsis the barrel — it exportsallSectionTypes(the array of section schemas) for registration inschemaTypes/index.ts, and re-exportssectionMembersfrom 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:
Studio schema —
studio/schemaTypes/sections/hero.tsdeclares the fields.Registry —
defineArrayMember({type: 'heroSection'})instudio/schemaTypes/sections/registry.tsadmits it tohomepage.sections[]/page.sections[].Website type —
website/src/sanity/types.tsdeclares a discriminated-union member keyed on_type, then adds it to theSectionDataunion:export interface HeroSectionData extends SectionBase { _type: 'heroSection'; title?: string; supporting?: string; // … } export type SectionData = | HeroSectionData | AboutSectionData // … one variant per section type …Website renderer —
website/src/components/SectionRenderer.astrois aswitch (s._type)over that union, with onecase '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 inwebsite/src/sanity/types.tsandSectionRenderer.astro.
Two cases:
- Adding a field to an existing section — add the
defineFieldin the Studio schema, add the optional property to that section's interface intypes.ts, and thread it through the matchingcasebranch inSectionRenderer.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 inindex.ts+ add toregistry.ts+ acasebranch + aSectionDatavariant). The full step-by-step is on Adding a section; the studioCLAUDE.mdhas 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
sanityclient inwebsite/src/lib/sanity.ts; draft-aware reads go throughsanityPreview. Route files pick the right one per request viagetSanityClient(Astro.cookies)/getRouteClientinwebsite/src/lib/sanity-draft.ts— never import the raw clients into a route. - Shared GROQ lives in
website/src/sanity/queries/; thesectionProjectionfragment inprojections.tsis 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 withnpx sanity deployfrom thestudio/dir towhitetreedigital.sanity.studio. The production preview URL is baked in fromstudio/.env.productionat deploy time.website/deploys to Cloudflare Workers from a Git push tomain. The site builds two ways from the one repo — a static public build and an SSR staging build — selected byBUILD_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
| Concern | File / location |
|---|---|
| Monorepo intro + cross-package rules | CLAUDE.md (root) |
| Website stack, layout, conventions | website/CLAUDE.md |
| Studio facts, schema overview, page-builder pattern | studio/CLAUDE.md |
| Annotated website source tree | website/src/ |
Section dispatch (switch (s._type)) | website/src/components/SectionRenderer.astro |
Section discriminated union (SectionData, SectionBase) | website/src/sanity/types.ts |
GROQ queries + shared sectionProjection | website/src/sanity/queries/ |
| Read clients + draft-aware picker | website/src/lib/sanity.ts, website/src/lib/sanity-draft.ts |
| One object schema per section | studio/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 schemas | studio/schemaTypes/*.ts |
| Mutation tooling (seed / migration / init) | studio/scripts/ |