Content & Sanity
Content model (document types)
White Tree Digital stores all of its copy and structured content in a single Sanity dataset, and the website only ever reads from it; this page is the field-by-field map of every document type the Studio defines.
The document types fall into two buckets. Singletons are one-of-a-kind documents whose id matches their type name — there is exactly one homepage, one siteSettings, etc. Repeatables are the collections an editor adds to over time — blog posts, services, testimonials. Most routable documents are page-builder driven: their body is a sections[] array assembled from the shared section registry, so the same menu of section types composes the homepage, generic pages, services, and portfolio entries. This page documents the documents; the section object types that fill those arrays live on Section types, and the GROQ projections that read all of this are on Querying content.
Every schema lives one-file-per-type under studio/schemaTypes/ and is registered in studio/schemaTypes/index.ts. For the mental model of schema-vs-content and how edits reach the site, see Sanity mental model; for singleton pinning, the structure builder, and visual editing, see Studio & singletons.
Never delete a singleton document
Running npx sanity documents delete <singleton-id> to "reset" a singleton tombstones the id permanently — Studio's initialValue can't recover it. Recovery is only possible via client.createOrReplace(...) (the pattern in studio/scripts/init-site-styles.ts). This applies to siteSettings, globalStyles, homepage, blogIndex, portfolioIndex, and psiSnapshot.
Singletons
Six types are locked to a single document (the SINGLETON_TYPES set in sanity.config.ts). Five are editor-facing (siteSettings, globalStyles, homepage, blogIndex, portfolioIndex) — though globalStyles is bootstrapped by a script rather than initialValue; one is machine-driven (psiSnapshot, written by a weekly Action).
siteSettings — global chrome
The site-wide shell that wraps every page: identity, header, navigation, footer, contact, social, and default SEO. Fields are organized into editor groups (general, header, nav, footer, contact, social, seo):
- Identity —
siteName(required, defaults toWhite Tree Digital) andtagline(≤160 chars, doubles as the fallback OG description). - Header — optional
headerLogo(+headerLogoSizepx),headerTextwordmark (HTML allowed for accent spans), and aheaderCtaobject (label+href). Leave the CTA's fields blank to hide it. nav— the primary nav array (≤7 items). EachnavItemhas alabel+href, plus an optionalchildrenarray (≤10) that turns the item into a dropdown. Dropdown children are either anavChildRef(areferenceto aservice/page/caseStudy/post, with optional label/description overrides) or anavChildLink(manuallabel+href).- Footer —
footerColumns[](each a titledlinks[]list), a rich-textfooterTextblurb, afooterNoteprefix string, andlegalLinks[](≤5). - Contact / social — a
contactobject (email/phone/address) and asocialarray of{platform, url}pairs. defaultSeo— an embeddedseoobject used as the fallback for any page without its own.
The copyright line is appended in code, not stored
footerNote is only the editor-controlled prefix on the copyright bar. The website always appends © <year> White Tree Digital, LLC. All rights reserved. automatically — it is one of the few strings that legitimately lives in code rather than Sanity. Don't try to author the full copyright line in footerNote.
globalStyles — the color theme
The site-wide color theme: ten type: 'color' tokens (from the @sanity/color-input plugin) that mirror the Tailwind @theme set in website/src/styles/global.css — colorPaper, colorPaper2, colorInk (+ colorInk2/colorInk3), colorForest (+ colorForestDeep), colorGold (+ colorGoldDeep), and colorRule. Layout.astro reads them and injects :root { --color-*: … } overrides. See Brand tokens & theme for how the tokens flow into Tailwind.
globalStyles colors are bootstrapped by a script, not initialValue
The schema deliberately defines no initialValue for the colors:
// Color defaults are populated via scripts/init-site-styles.ts (not via
// initialValue — see studio/CLAUDE.md "Global Styles bootstrap" for why).
Two reasons: @sanity/color-input needs the full value shape (hex + hsl + hsv + rgb sub-objects) to render a swatch — bare hex shows the empty "Create color" state — and initialValue doesn't fire reliably for plugin field types in Sanity v5. Defaults are written by studio/scripts/init-site-styles.ts via createOrReplace. Re-run it any time to reset the brand palette.
homepage — the page builder root
The homepage is purely page-builder driven: a single sections[] array (typed by the shared sectionMembers registry) plus an embedded seo object. Editors add, reorder, and configure sections; the website renders whatever's there, in order. Nothing else lives on the singleton.
blogIndex / portfolioIndex — the listing pages
blogIndex (/blog) and portfolioIndex (/portfolio) share the same shape: an internal title, an eyebrow, a heading (the page H1), an intro, a gridVariant radio (standard / featured / mosaic), an optional sectionsBelow[] page-builder array rendered under the post grid, and seo.
The post list is fetched, not stored
Neither index singleton holds its own list of posts. The website's GROQ query auto-fetches the post docs (for blogIndex) or portfolioPost docs (for portfolioIndex) at request time, sorted by publishedAt. The singleton only owns the page chrome and the optional sections below the grid.
The heading field uses the accent-word convention: wrap highlighted words in *asterisks* (e.g. Notes from *the workshop*.), which the website's renderAccentHeading helper renders in the accent color — gold on dark surfaces, forest-green on cream. See Typography.
psiSnapshot — this site's PageSpeed scores
A machine-written singleton holding this site's own Google PageSpeed Insights (mobile) scores: performance, accessibility, bestPractices, seo (each 0–100), plus meta (auditedUrl, strategy, fetchedAt). It feeds the homepage PageSpeed dials section.
psiSnapshot is write-only by an Action — read-only in Studio
Every field is readOnly: true in Studio on purpose. The only writer is the website repo's weekly GitHub Action (website/scripts/refresh-psi.ts via createOrReplace); the website itself only ever reads it. Hand-editing a metric would be misleading and get clobbered on the next run. readOnly is editor-side only — the token-authenticated API write is unaffected.
Page documents
page — generic page-builder pages
The catch-all document for non-singleton routes: title, a slug, a sections[] array (the shared registry), and seo. Served by the website's [slug].astro catch-all — create and publish a page doc to add a new root-level route like /about. The /services route is itself a page doc today. See Routing & pages.
Some slugs are reserved
The slug field rejects paths owned by dedicated website route files or API paths — a page doc with one of these slugs would never be served:
const RESERVED_SLUGS = ['blog', 'portfolio', 'free-website-audit', 'api', '404', 'sitemap.xml', 'robots.txt']
Keep this list in sync with website/src/pages/.
service — service detail pages
Page-builder driven, but with card/preview fields that stay on the document for the services grid: title (≤60), slug, eyebrow (the mono label, e.g. 01 / SEO), summary (used on cards), and order (lower sorts first; falls back to creation date). All body content lives in sections[]. Plus relatedCaseStudies (a reference[] to caseStudy, ≤3) and seo.
The key relation: relatedCaseStudies is the route-level fallback for a workSection on this service's page. When a workSection placed on a service detail page has no manual case-study picks, the /services/[slug] route passes the service's relatedCaseStudies in as the fallback — so the section fills with that service's curated work instead of going empty.
portfolioPost — portfolio entries
A portfolio entry rendered at /portfolio/<slug>, page-builder driven. Card/preview fields on the doc: title, slug, summary, an optional client reference, industry (a short eyebrow tag), a services reference array, coverImage, and publishedAt. The body is a full sections[] page-builder array. Two notable extras:
headlineStat— an object{value, label}(e.g.value: '+412%') for the hero band and index card. Skip it when there's no clean number.variant— a layout radio (centered/sidebar/editorial) controlling the page treatment, plusrelatedPortfolioPosts(≤3) as a manual override for the "more work" grid.
Listed on /portfolio automatically. See Blog & portfolio.
portfolioPost and caseStudy coexist — pick a direction
portfolioPost (page-builder, /portfolio/[slug]) and the legacy caseStudy (fixed body, /work/[slug]) are two work-detail document types living side by side. They serve different routes and caseStudy still feeds the homepage workSection fallback, so removing it isn't free. The keep/deprecate/migrate decision and a field-by-field migration plan are tracked in the root TODO.md ("caseStudy vs portfolioPost"). Don't assume one is dead.
caseStudy — legacy work detail
The older work-detail document, rendered at /work/<slug>. Unlike portfolioPost it is not page-builder driven — it has a fixed shape: title, slug, optional client reference, industry, services, summary, coverImage, a cardColorway radio (forest / gold / ink), a required headlineStat string, an outcomeStats[] array (2–4 {value, label, sub} stats for the receipts band), a Portable Text body, an optional testimonial reference, publishedAt, order, relatedCaseStudies (≤2), and seo.
Its load-bearing role: caseStudy documents are the auto-fallback list for the homepage workSection (via the recentCaseStudiesProjection). That's why it can't simply be deleted in favor of portfolioPost.
The service-interest list
explorerService — canonical interests + HubSpot contract
The canonical service-interest list (shown as "Service Explorer" in the Studio sidebar) — one document per interest. Both explorer sections (heroExplorerSection, servicesExplorerSection) reference these docs through an optional services picks array, so editing one item here updates every explorer instance. Fields split into three groups: core, hero (chip context: heroLabel / heroDescription / heroImage), and accordion (row context: accordionLabel / accordionDescription / accordionImage). The hero/accordion labels fall back to the canonical title when blank, and order controls the sort when an explorer shows all items.
This type is distinct from service on purpose: an interest doesn't need a service detail page, whereas service docs auto-appear in the services grid. When an explorer section's services picks are empty, the website projection falls back to all explorerService items ordered by order.
`value` is the HubSpot form contract
The value slug travels to the contact form as a query string and must exactly match an option value on the HubSpot interested_services property — otherwise the checkbox won't pre-fill:
description:
'Sent to the contact form as ?interested_services=<value>. Slug-cased, e.g. "web-design". ' +
'Must exactly match an option value on the HubSpot "interested_services" property…'
When you add an explorerService item, add the matching option in HubSpot too. The explorer CTA emits multiple selections as a semicolon-separated multi-checkbox query string (the param key defaults to interested_services, configurable per-explorer via queryParam). See HubSpot & lead capture.
Supporting & relational documents
These repeatable types are referenced by the documents and sections above; several carry a featured flag that drives the no-pick fallback for reference-mode sections.
post — blog posts
The blog article type, routed at /blog/[slug] and auto-listed on /blog. Core fields: title, slug, excerpt (used on the index and as the fallback meta description), a required author reference, publishedAt (defaults to now), coverImage, and tags. The body is long-form rich text via the shared richBlockContent Portable Text definition (H2/H3/H4, blockquotes, lists, bold/italic/underline, links, inline images — see Querying content and the Studio's lib/blockContent.ts). A variant radio (centered / sidebar / editorial) sets the page layout, and relatedServices / relatedPosts reference arrays drive internal cross-links.
author — post bylines
name, slug, role, photo, a Portable Text bio, and a socials array of {platform, url}. Referenced by post.author (required on every post).
testimonial — quotes
quote (required, ≤400), attribution (name / title / company), optional photo and companyLogo, and an optional relatedCaseStudy reference. featured: true is the auto-fallback when a testimonialsSection has no manual picks — so the flag is load-bearing, not cosmetic.
client — logo strip
name, slug, logo (SVG preferred), url, industry, plus featured (boolean) and order (number). featured: true + order are the auto-fallback for a logoWallSection with no manual picks. Also the reference target of portfolioPost.client and caseStudy.client.
seo — the embedded object
Not a document — a reusable object type embedded on every routable doc (metaTitle, metaDescription, ogImage). It carries per-page overrides; anything left blank falls back to siteSettings.defaultSeo. Full handling on SEO & structured data.
The featured / order flags are how empty sections fill themselves
Reference-mode sections (logoWallSection, testimonialsSection, workSection) accept arrays of references but auto-fall-back when left empty: client.featured + order, testimonial.featured, and recent caseStudy docs respectively. The fallback is implemented in the website's GROQ projections, not in the schemas — see Querying content and Section types.
Image alt is optional everywhere
Across these schemas, image alt subfields are deliberately not .required(). Decorative images legitimately ship with alt="", and blocking a save over alt copy just invites filler ("image", "logo"). The rendered <img> uses alt={image?.alt ?? ''}, which screen readers correctly skip. See Accessibility.
An image with only `alt` set throws in urlFor
A "ghost" image object — alt filled but no asset uploaded — has no asset reference, and urlFor() throws on it. Website components guard on ?.asset (not on the image object's mere presence) before building a URL. This bites when an editor types alt text first and uploads later. See Images & fonts.
Where this lives
| Concern | Path |
|---|---|
| Schema files (one per type) | studio/schemaTypes/*.ts |
| Schema registration | studio/schemaTypes/index.ts |
| Section registry (page-builder menu) | studio/schemaTypes/sections/registry.ts |
| Shared rich-text body | studio/lib/blockContent.ts |
| globalStyles bootstrap script | studio/scripts/init-site-styles.ts |
| psiSnapshot writer (weekly Action) | website/scripts/refresh-psi.ts |
| Website-side type contract | website/src/sanity/types.ts |
| Schema overview table | studio/CLAUDE.md ("Schema overview") |
| Document-type list (website view) | website/CLAUDE.md ("Sanity") |
| caseStudy ↔ portfolioPost decision | TODO.md ("caseStudy vs portfolioPost") |
A schema change in studio/ that affects rendered fields requires matching updates to website/src/sanity/types.ts and the GROQ projections — see Querying content and Adding a section.