Content & Sanity
Sanity mental model
The single unlock for working with Sanity here is that schema and content are two different things that live in different places and travel different paths — schema is TypeScript in the studio/ repo that you deploy, content is JSON in Sanity's cloud database that you edit in the Studio UI, and the website only ever reads the latter.
Get those two straight and almost every "why didn't my change show up?" question answers itself. This page covers the schema-vs-content split, the two directions data flows (IDE → Studio for schema, Studio → website for content), the single production dataset, and how far page composition is taken. For the actual document-type catalog see Content model; for Studio structure and singletons see Studio & singletons; for the GROQ queries see Querying content.
Schema vs content
| Schema (the shape) | Content (the values) | |
|---|---|---|
| Where it lives | IDE → studio/ repo → git (TypeScript .ts files) | Sanity cloud database (JSON documents, not files) |
| Who edits it | You, in code | You (or an editor), in Studio's web UI |
| How it reaches its destination | npx sanity deploy → hosted Studio | Astro GROQ fetch → website HTML |
| Version-controlled | Yes (git) | No (Sanity document history only) |
When someone says "I edited something in the IDE, why isn't it in Studio?" or "I published in Studio, why isn't it on my local machine?", the answer always depends on which of these two they mean. They are decoupled systems that only meet through the two flows below.
Pushing code never changes content (and vice versa)
git push on either repo does not mutate documents in Sanity, and publishing a document in Studio does not write a file to your machine or your repo. The one exception is npx sanity deploy, which pushes schema (code) up to the hosted Studio. Content and code travel on separate rails.
Direction 1 — IDE → Studio (schema)
This direction carries schema only — you're editing the form editors see, not the values inside it. The hosted Studio at https://whitetreedigital.sanity.studio does not auto-update when you change schema files locally. A schema change is a code deployment, not a live edit.
The loop, run from the studio/ directory:
npm run dev # local Studio at http://localhost:3333 (same production dataset)
# add/verify the new field, then:
npm run deploy # = npx sanity deploy — uploads the rebuilt Studio bundle
After deploy completes, whitetreedigital.sanity.studio shows the new field. Then commit + push to git so the source of record stays in sync. Project ID 8ftz0iuv and the production dataset are declared in studio/sanity.config.ts and studio/sanity.cli.ts.
This is also the only direction in which the IDE sends anything to Studio. You cannot push content this way.
Never delete a singleton to reset it
Do not run npx sanity documents delete <singleton-id> (e.g. globalStyles) to "start over." Sanity tombstones the ID, and Studio's initialValue will never recover it. The only recovery path is client.createOrReplace(...) — see studio/scripts/init-site-styles.ts for the pattern. This applies to all five singletons (siteSettings, globalStyles, homepage, blogIndex, portfolioIndex).
Direction 2 — Studio → website (content)
This is the direction that feels mysterious until it clicks: content edited in Studio never lands as a file on your machine. It lives only in Sanity's database. The website reads it through GROQ.
Edit in Studio → Publish → Sanity DB updated (~ms) → website fetches via GROQ → HTML
How the fetch happens differs between the two website builds (full rationale in Two-build split):
- Static public build — Astro runs
sanity.fetch(...)queries at build time (astro build); the fetched content is interpolated into HTML and the static files are deployed to Cloudflare. The live site keeps serving the previously-built HTML, embedding the previously-fetched content, until something triggers a rebuild. - SSR staging build —
output: 'server'on Cloudflare Workers re-renders each route per request, so a publish appears within the CDN cache window (the published client usesuseCdn: true).
Because the public site is static, a publish alone does not make the change appear there. Two ways to bridge the gap:
- Manual — fire the Studio's top-nav Deploy tool, which POSTs a Cloudflare deploy hook to rebuild the public site. (Any
git pushto the website repo also triggers a Cloudflare build.) - Automated — a Sanity webhook → Cloudflare deploy hook, firing on document create/update/delete.
Publish ≠ Deploy — the labels are kept distinct on purpose
Sanity's per-document Publish action publishes one document to the dataset (instant). The Studio's Deploy tool rebuilds the whole public site. They are deliberately named differently so editors don't conflate them — and the Deploy trigger is manual so that iterative drafts don't each fire a wasteful build. Do not wire Sanity's document webhooks to that deploy hook; the manual trigger is the whole point. See studio/components/DeployTool.tsx.
What does NOT flow
A few intuitions that are easy to fall into and wrong:
- "My local site has a copy of the content I can edit." No — the local site has only the fetcher; the data lives in the cloud. Edit in Studio, refresh, fresh data appears.
- "If I edit a fallback string in a section component, that updates Studio." No. In fact there are no copy fallbacks — Sanity is the single source of truth for copy; an empty field renders nothing. (Default copy for new instances comes from Studio-side
initialValue+ seed scripts, not from the.astrocomponent. See Page builder.) - "Drafts and published live in different datasets." No — they live in the same dataset, side by side.
drafts.<id>is the unpublished working copy;<id>is the live version.
One production dataset
There is a single dataset, production. The previous development sandbox was deleted.
The toggle you see in Studio is published vs draft — a per-document lifecycle state, not a dataset switcher. Every document has two parallel rows in the same dataset: a drafts.<id> copy you're editing and a published <id> copy. The website's published client reads only <id>; the preview client (used in draft mode) can read drafts.
A dataset selector only appears when sanity.config.ts defines multiple workspaces. It currently exports a single workspace bound to production, so the Studio serves at the root path with no per-workspace prefix. sanity.cli.ts also pins dataset: 'production' so CLI commands target it by default. Staging behaviour is provided by per-document drafts, not a separate dataset.
If a staging dataset ever becomes necessary, switch defineConfig({…}) back to the array form defineConfig([{…}, {…}]) with two workspace objects (each given a non-root basePath), and create it with:
npx sanity dataset create staging
The read token is never in .env
SANITY_API_READ_TOKEN (used by draft mode) lives in .dev.vars locally and as an encrypted secret in the Cloudflare Workers dashboard for production. Never in .env, never committed. The website's public, build-time config only needs the project ID, dataset name, and pinned API version.
How far page composition is taken
Sanity is a content database, not a design tool. It lets editors place instances of section types — toggle them on/off, reorder, swap content, pick a variant, duplicate — but it cannot invent a new visual section. Every new section type is a coordinated pair:
- a schema in
studio/schemaTypes/sections/...— the editable fields - an Astro component in
website/src/components/sections/...— the visual output
Once a pair is installed, Sanity can place arbitrarily many instances of it. The first instance always costs code. The original design note framed this as a spectrum of editor freedom:
| Level | Editor controls | Code controls |
|---|---|---|
| 1. Fields only | Values inside fixed sections | Section types, order, presence |
| 2. Toggle + order | Which sections appear, in what order; values | The library of section types |
| 3. Variants | Section variant (compact/wide/dark) + values | Section types and variants |
| 4. Full page builder | Compose pages freely; multiple instances of a type allowed | The library of section types |
| 5. Composable bodies | Free Portable Text with embedded section blocks inline | Section types + the rich-text editor |
The repo has gone past the level 1–2 recommendation
The original Sanity Page Composition.md note recommended staying at level 1–2 for a one-person marketing site, evolving to level 4 only when a specific page demanded it. The codebase has since adopted level 4: homepage, page, service, portfolioPost, and both index singletons each carry a sections[] array of typed section objects, dispatched by _type. So the recommendation reads as historical context — the actual mental model to hold today is the level-4 one below.
The live level-4 mechanics (see Page builder for the full walkthrough):
studio/schemaTypes/sections/registry.tsexportssectionMembers, the singledefineArrayMember[]list of which section types exist. Bothhomepage.tsandpage.ts(and the other page-builder docs) import it as theirsections.of— one source of truth.- Each section is its own object schema (
heroSection,faqSection, …), camelCase +Sectionsuffix, no dots in the name (Sanity requires JS-identifier names). - The website's
SectionRenderer.astrois aswitch (s._type)over the discriminated union, dispatching each block to its*Section.astrocomponent.
Adding a brand-new section type touches both packages — schema, registry entry, component, SectionRenderer case, and a SectionData variant in website/src/sanity/types.ts. The step-by-step lives in Adding a section.
What deliberately stays out of any page builder: the voice-locked homepage hero copy, the fixed /[slug] route structure for service/portfolio detail pages (hero → deliverables → process → pricing → FAQ → CTA — letting editors reorder it would undermine the SEO floor), and section visual variants ("Hero on Paper vs Hero on Ink" is a design decision code owns). Promote a variant to Sanity only when there's a real editorial reason.
Quick reference
| You want to… | You do this in… | And it lands when… |
|---|---|---|
| Add/change a field on a doc type | IDE (studio/schemaTypes/*.ts) | you run npx sanity deploy |
| Change form labels / validation | IDE (studio/schemaTypes/*.ts) | you run npx sanity deploy |
| Add/edit content (text, images, refs) | Studio (web UI) | you Publish; public site shows it on next build/Deploy |
| Change layout / styling | IDE (Astro/Tailwind in website/) | you commit + Cloudflare rebuilds |
| Change which dataset the website reads | IDE (env config) | you restart dev / rebuild for prod |
| Change which dataset the hosted Studio reads | IDE (sanity.config.ts) + sanity deploy | the deploy completes |
Where this lives
studio/sanity.config.ts,studio/sanity.cli.ts— singleproductionworkspace, project ID8ftz0iuv, structure builder + singleton pinning.studio/schemaTypes/— every document and object schema;sections/registry.tsis the section-type source of truth.studio/components/DeployTool.tsx— the Studio "Deploy" tool that POSTs the Cloudflare deploy hook.studio/scripts/init-site-styles.ts— thecreateOrReplacesingleton-recovery pattern.website/src/lib/sanity.ts— read clients (sanitypublished,sanityPreviewstega-enabled); API version pinned to2026-05-01.website/src/lib/sanity-draft.ts—getSanityClient(Astro.cookies), the per-request published-vs-draft picker.website/src/components/SectionRenderer.astro— theswitch (_type)page-builder dispatch.website/src/sanity/queries/— the GROQ projections; see Querying content.- Original design notes:
Sanity Mental Model.mdandSanity Page Composition.mdat the repo root.