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):

  • IdentitysiteName (required, defaults to White Tree Digital) and tagline (≤160 chars, doubles as the fallback OG description).
  • Header — optional headerLogo (+ headerLogoSize px), headerText wordmark (HTML allowed for accent spans), and a headerCta object (label + href). Leave the CTA's fields blank to hide it.
  • nav — the primary nav array (≤7 items). Each navItem has a label + href, plus an optional children array (≤10) that turns the item into a dropdown. Dropdown children are either a navChildRef (a reference to a service / page / caseStudy / post, with optional label/description overrides) or a navChildLink (manual label + href).
  • FooterfooterColumns[] (each a titled links[] list), a rich-text footerText blurb, a footerNote prefix string, and legalLinks[] (≤5).
  • Contact / social — a contact object (email / phone / address) and a social array of {platform, url} pairs.
  • defaultSeo — an embedded seo object 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.csscolorPaper, 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, plus relatedPortfolioPosts (≤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

ConcernPath
Schema files (one per type)studio/schemaTypes/*.ts
Schema registrationstudio/schemaTypes/index.ts
Section registry (page-builder menu)studio/schemaTypes/sections/registry.ts
Shared rich-text bodystudio/lib/blockContent.ts
globalStyles bootstrap scriptstudio/scripts/init-site-styles.ts
psiSnapshot writer (weekly Action)website/scripts/refresh-psi.ts
Website-side type contractwebsite/src/sanity/types.ts
Schema overview tablestudio/CLAUDE.md ("Schema overview")
Document-type list (website view)website/CLAUDE.md ("Sanity")
caseStudy ↔ portfolioPost decisionTODO.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.

Previous
Sanity mental model