Content & Sanity
Studio & singletons
The Sanity Studio is one production workspace whose left-hand "desk" is hand-built so the page-level singletons sit pinned at the top, every other document type files below dividers, and the documents that should never be duplicated or deleted have those actions removed.
The Studio lives in studio/ and authors content for the marketing website, which only ever reads the dataset. Project ID 8ftz0iuv, single dataset production, Sanity v5 + React 19. This page covers how the desk is assembled, which documents are singletons and how they are protected, and how default copy gets into those documents — because none of it happens by accident. For the document/field shapes themselves see Content model; for the Sanity read/write mental model see Sanity overview; for split-screen visual editing see Presentation Tool & draft mode.
One production workspace
studio/sanity.config.ts exports a single defineConfig({...}) — name: 'production', dataset: 'production'. The Studio serves at the root path of its host (https://whitetreedigital.sanity.studio/) with no per-workspace prefix. studio/sanity.cli.ts pins the same dataset: 'production', so CLI commands (sanity exec, sanity dataset list, the seed scripts below) target production by default.
There is no development dataset anymore — the old sandbox was deleted. "Staging" behavior is provided by Sanity's per-document draft vs. published lifecycle (the toggle inside each document), not by a separate dataset. If a real staging dataset is ever needed, switch back to defineConfig([{...}, {...}]) with two workspace objects, give each a non-root basePath, and npx sanity dataset create staging.
The config registers four plugins — structureTool (with the custom desk below), presentationTool, visionTool, and colorInput — plus a custom top-nav Deploy tool that fires a Cloudflare deploy hook. The Deploy tool and Presentation wiring are documented in Presentation Tool & draft mode and Deployment; this page stays on structure and content defaults.
The desk layout
The structure builder (sharedStructure in sanity.config.ts, passed as structureTool({structure: sharedStructure})) replaces Sanity's default flat list with an explicit, ordered desk. From top to bottom:
- Site settings —
siteSettingssingleton - Global Styles —
globalStylessingleton - PageSpeed Snapshot —
psiSnapshotsingleton - Homepage —
homepagesingleton - Pages — the
pagedocument-type list - divider
- Blog Index —
blogIndexsingleton - Portfolio Index —
portfolioIndexsingleton - divider
- Portfolio posts (→ /portfolio) —
portfolioPost - Case studies (legacy · Work-section fallback) —
caseStudy - divider
- Service Explorer —
explorerService - …then every remaining document type, via
S.documentTypeListItems().filter(...)
Each singleton is a hand-written S.listItem() whose .child() is an S.document().schemaType(...).documentId(...) pinned to a fixed document ID equal to the type name. The non-singleton entries (portfolioPost, caseStudy, explorerService) get explicit titles so editors can tell the two work-content types apart — portfolioPost drives /portfolio today; caseStudy is the legacy type that only feeds the homepage workSection fallback. The trailing filter then drops everything already placed by hand (the singleton IDs, page, portfolioPost, caseStudy, explorerService) so nothing appears twice.
The singletons
A singleton is a document type that should exist exactly once. The set is declared once in sanity.config.ts:
const SINGLETON_TYPES = new Set<string>([
'siteSettings',
'globalStyles',
'psiSnapshot',
'homepage',
'blogIndex',
'portfolioIndex',
])
That is six types today (studio/CLAUDE.md's "Singleton mechanics" prose still says "five" — it predates psiSnapshot being added to the set; trust the config). Each is locked to a single document whose _id matches the type name (siteSettings, globalStyles, etc.).
| Singleton | What it holds |
|---|---|
siteSettings | Site name + wordmark, header logo + CTA, primary nav, footer (columns + rich-text blurb + legal links), contact, social, default SEO. |
globalStyles | The 10-token site-wide color theme (mirrors the Tailwind @theme set). Title in Studio is "Global Styles". |
psiSnapshot | This site's own Google PageSpeed (mobile) scores. Machine-written by a weekly GitHub Action — every field is readOnly in Studio. |
homepage | The homepage, page-builder driven (single sections[] array). |
blogIndex | The /blog index page (heading/intro + grid variant + optional sectionsBelow[] + SEO). |
portfolioIndex | The /portfolio index page, same shape as blogIndex. |
How they are pinned, filtered, and protected
The SINGLETON_TYPES set drives three independent guards:
Pinned in the desk. Each has its hand-written
S.listItem()entry (above), so it appears once at a fixed spot — not buried in an alphabetical type list.Removed from "new document" templates.
schema.templatesfilters out any template whoseschemaTypeis inSINGLETON_TYPES, so the global "Create new" menu never offers to make a secondhomepage,siteSettings, etc.templates: (templates) => templates.filter(({schemaType}) => !SINGLETON_TYPES.has(schemaType)),Document actions restricted to a safe allow-list. For any singleton type,
document.actionskeeps only the actions inSINGLETON_ACTIONS:const SINGLETON_ACTIONS = new Set<string>(['publish', 'discardChanges', 'restore']) // … actions: (input, context) => SINGLETON_TYPES.has(context.schemaType) ? input.filter(({action}) => action && SINGLETON_ACTIONS.has(action)) : input,Because it's an allow-list,
duplicate,unpublish, anddeleteare filtered out — editors can publish, discard draft changes, and restore from history, but cannot create a second copy or destroy the document.
Adding another singleton
Append the type name to SINGLETON_TYPES, then add a matching S.listItem() entry in sharedStructure (copy any existing singleton block and swap the title / id / schemaType / documentId). The template and action guards pick it up automatically from the set.
Never delete a Sanity singleton to reset it
Do not run npx sanity documents delete <singleton-id> to "start fresh" on a singleton. Sanity writes a tombstone for that _id, and Studio's initialValue will not recover it — the slot stays permanently dead. The only recovery is client.createOrReplace(...) against the same _id (which is exactly what scripts/init-site-styles.ts does for globalStyles). This is also why the delete action is stripped from singletons in the first place.
How default copy gets supplied
The website renders only what's in Sanity — there are no hardcoded copy fallbacks in the Astro components, so an empty field renders nothing. To make sections discoverable in the page builder and to make Presentation Tool show real content on first load, default copy is supplied by three mechanisms, not by initialValue everywhere. Which one applies depends on what is being defaulted.
1. Section defaults — lib/section-defaults.ts
For section objects (the things editors drop into sections[]), the single source of truth for default copy is studio/lib/section-defaults.ts. Each section type exports a <typeName>SectionDefaults constant, and the file also exports a sectionDefaultsByType lookup map keyed by _type:
export const sectionDefaultsByType: Record<string, Record<string, any>> = {
heroSection: heroSectionDefaults,
processSection: processSectionDefaults,
psiScoresSection: psiScoresSectionDefaults,
// …one entry per section type
}
Each section schema wires its constant into initialValue (spread, so instances don't share one object) so brand-new instances arrive pre-filled:
import {heroSectionDefaults} from '../../lib/section-defaults'
export const heroSection = defineType({
name: 'heroSection',
type: 'object',
initialValue: () => ({...heroSectionDefaults}),
fields: [ /* … */ ],
})
The same constants are re-used by the seed script (below) to backfill existing documents. Reference-bearing sections (logoWallSection, testimonialsSection, workSection) deliberately have no content defaults — their empty-state behavior is "fall back to featured/recent docs", handled in the website's GROQ projections, not here. See Adding a section for the full add-a-default checklist and Section types for what each section holds.
Meaningful placeholders, never empty strings
Every editor-visible string default is real placeholder copy (eyebrow: 'Eyebrow text', heading: 'Section heading') — never '', never omitted. An empty field renders nothing, so the editor can't see that the slot exists. Worse: in draft preview Sanity stega-encodes strings with invisible Unicode tag characters, so an empty-string default becomes a string of zero-width chars that is truthy in JS — {eyebrow && …} then renders an empty, styled <span>. Backspacing to empty stores null and hides correctly, but the default must never ship as ''. (This is the same stega-breaks-equality trap covered in Sanity overview.)
2. Singleton & page seeds — scripts/seed-singletons.ts
Singletons skip initialValue (they're created once, not minted from a template), so their default fields are backfilled by a script. scripts/seed-singletons.ts fills missing fields on siteSettings, homepage.seo, and the services page doc's seo using setIfMissing — non-destructive, idempotent, existing editor values are never overwritten:
const SITE_SETTINGS_SEEDS: FieldSeed[] = [
{path: 'headerCta', value: {label: 'Book a call', href: '/contact'}},
{path: 'footerText', value: makeFooterText()},
{path: 'footerNote', value: 'Indianapolis, IN ·'},
{path: 'legalLinks', value: []},
]
The /services route reads a page document with slug.current == 'services', so the script looks that ID up at runtime rather than hardcoding it. Run it with:
npx sanity exec scripts/seed-singletons.ts --with-user-token
A separate script, scripts/seed-homepage-sections.ts, backfills empty fields inside a document's sections[] using sectionDefaultsByType. It patches both the published doc and its draft (Studio and Presentation read the draft when one exists, so a published-only patch would be invisible), and it deep-clones each default and stamps a fresh _key on every array member before writing — both because Sanity doesn't auto-_key items from defaults and to avoid _key collisions across sections. It only fills fields that are undefined/null/empty-string/empty-array, so it never stomps customized copy:
npx sanity exec scripts/seed-homepage-sections.ts --with-user-token
# target another page-builder doc:
DOC_ID=<page-id> npx sanity exec scripts/seed-homepage-sections.ts --with-user-token
initialValue is not exempt from validation
Default copy still has to pass the field's validation rules. If a default would exceed a .max() or fail a .required() on a nested field, Studio shows the warning on the freshly-created instance. Keep defaults inside validation bounds.
3. Global Styles bootstrap — scripts/init-site-styles.ts
globalStyles is the exception that needs its own script. Its color fields use type: 'color' from @sanity/color-input, and that picker renders from hsl / hsv / rgb sub-objects — a bare {_type: 'color', hex, alpha} value shows the empty "Create color" state instead of a swatch. On top of that, initialValue doesn't fire reliably for plugin-provided field types in Sanity v5. So the schema deliberately carries no initialValue, and defaults are written by scripts/init-site-styles.ts with client.createOrReplace:
const doc = {
_id: 'globalStyles',
_type: 'globalStyles',
colorPaper: color('#f5f1e6'),
colorInk: color('#0e1d15'),
colorForest: color('#1f5d3a'),
colorGold: color('#e8c46a'),
colorRule: color('#0e1d15', 0.14),
// …10 tokens total
}
await client.createOrReplace(doc)
The color() helper (studio/lib/color.ts) computes the full hex + hsl + hsv + rgb shape the picker needs from a single hex string; it's the same helper section-defaults uses for its color presets. The init script also deletes drafts.globalStyles afterward so Studio shows the published values rather than a stale draft. It's idempotent (createOrReplace) — re-run any time to reset colors to brand defaults:
npx sanity exec scripts/init-site-styles.ts --with-user-token
To add a new color token: add it to both the schema's fields[] and the script's doc = {...}, then re-run. For how those tokens reach the rendered CSS see Brand tokens & theme.
Reset globalStyles with createOrReplace, not delete
Same tombstone rule as any singleton: never npx sanity documents delete globalStyles to reset it. Deleting tombstones the _id and leaves createOrReplace as the only recovery path — which is what the init script already uses. Just re-run the init script.
Where this lives
| Concern | File |
|---|---|
| Workspace, desk, singleton guards, plugins | studio/sanity.config.ts |
| CLI dataset pin, auto-updates | studio/sanity.cli.ts |
| Section default copy (source of truth + lookup map) | studio/lib/section-defaults.ts |
| Color-input value-shape helper | studio/lib/color.ts |
Backfill singletons + services page SEO | studio/scripts/seed-singletons.ts |
Backfill sections[] defaults (published + draft) | studio/scripts/seed-homepage-sections.ts |
Bootstrap globalStyles colors | studio/scripts/init-site-styles.ts |
globalStyles schema (no initialValue) | studio/schemaTypes/globalStyles.ts |
psiSnapshot schema (read-only singleton) | studio/schemaTypes/psiSnapshot.ts |
| Narrative conventions & checklists | studio/CLAUDE.md |
Related pages: Sanity overview · Content model · Page builder · Adding a section · Presentation Tool & draft mode · Brand tokens & theme.