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 livesIDE → studio/ repo → git (TypeScript .ts files)Sanity cloud database (JSON documents, not files)
Who edits itYou, in codeYou (or an editor), in Studio's web UI
How it reaches its destinationnpx sanity deploy → hosted StudioAstro GROQ fetch → website HTML
Version-controlledYes (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 buildoutput: 'server' on Cloudflare Workers re-renders each route per request, so a publish appears within the CDN cache window (the published client uses useCdn: 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 push to 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 .astro component. 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:

LevelEditor controlsCode controls
1. Fields onlyValues inside fixed sectionsSection types, order, presence
2. Toggle + orderWhich sections appear, in what order; valuesThe library of section types
3. VariantsSection variant (compact/wide/dark) + valuesSection types and variants
4. Full page builderCompose pages freely; multiple instances of a type allowedThe library of section types
5. Composable bodiesFree Portable Text with embedded section blocks inlineSection 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.ts exports sectionMembers, the single defineArrayMember[] list of which section types exist. Both homepage.ts and page.ts (and the other page-builder docs) import it as their sections.of — one source of truth.
  • Each section is its own object schema (heroSection, faqSection, …), camelCase + Section suffix, no dots in the name (Sanity requires JS-identifier names).
  • The website's SectionRenderer.astro is a switch (s._type) over the discriminated union, dispatching each block to its *Section.astro component.

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 typeIDE (studio/schemaTypes/*.ts)you run npx sanity deploy
Change form labels / validationIDE (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 / stylingIDE (Astro/Tailwind in website/)you commit + Cloudflare rebuilds
Change which dataset the website readsIDE (env config)you restart dev / rebuild for prod
Change which dataset the hosted Studio readsIDE (sanity.config.ts) + sanity deploythe deploy completes

Where this lives

  • studio/sanity.config.ts, studio/sanity.cli.ts — single production workspace, project ID 8ftz0iuv, structure builder + singleton pinning.
  • studio/schemaTypes/ — every document and object schema; sections/registry.ts is 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 — the createOrReplace singleton-recovery pattern.
  • website/src/lib/sanity.ts — read clients (sanity published, sanityPreview stega-enabled); API version pinned to 2026-05-01.
  • website/src/lib/sanity-draft.tsgetSanityClient(Astro.cookies), the per-request published-vs-draft picker.
  • website/src/components/SectionRenderer.astro — the switch (_type) page-builder dispatch.
  • website/src/sanity/queries/ — the GROQ projections; see Querying content.
  • Original design notes: Sanity Mental Model.md and Sanity Page Composition.md at the repo root.
Previous
Routing & pages