Content & Sanity
Querying content (GROQ)
The website reads content from Sanity through two clients, a cookie-aware picker that routes choose between them automatically, and a set of GROQ query helpers that share one projection vocabulary — so every page-builder document fetches sections the same way.
This page covers the read-side plumbing in website/src/lib/ and website/src/sanity/queries/: which client to use and when, the helper functions routes call, the shared sectionProjection fragment, the hideSection filter, and image URL building. Draft preview and stega encoding get a deep treatment on Presentation Tool & draft mode; here we only touch them where they affect query code. For the document types you're querying, see Content model; for how fetched sections are rendered, see Page builder overview.
Two clients: published vs. preview
website/src/lib/sanity.ts builds two clients from one shared baseConfig:
const baseConfig: ClientConfig = {
projectId,
dataset,
apiVersion: '2026-05-01',
useCdn: true,
perspective: 'published',
}
export const sanity = createClient(baseConfig)
sanity— the published client.useCdn: true,perspective: 'published'. This is the default everywhere: build-time fetches, the sitemap, theme tokens, image URLs. It returns plain JSON.sanityPreview— the stega-encoded draft client. Same base config butuseCdn: false,perspective: 'drafts', andstega: {enabled: true, studioUrl}. It encodes every string field with invisible source tags so the Presentation Tool overlay can bind click-to-edit.
projectId and dataset come from PUBLIC_SANITY_PROJECT_ID / PUBLIC_SANITY_DATASET; the module throws at import if either is missing. studioUrl (PUBLIC_SANITY_STUDIO_URL) is where stega source-links resolve to and defaults to http://localhost:3333/production. See Environment variables.
The preview client carries no token at module level
SANITY_API_READ_TOKEN is a runtime secret on Cloudflare Workers, not a build-time env var. It is never baked into the module-level sanityPreview. Instead it's attached per-request with sanityPreview.withConfig({token}) inside the client picker. Never put the read token in .env or commit it — it lives in .dev.vars locally and as an encrypted Secret in the CF dashboard. See Security.
The pinned apiVersion: '2026-05-01' is deliberate — never use a floating version. It must stay in lockstep across the codebase; website/scripts/refresh-psi.ts declares its own API_VERSION constant with a comment requiring it to match sanity.ts. Bump it only after reading the Sanity changelog.
Pick the client from the route, never import directly
Routes don't import sanity or sanityPreview themselves. They call a selector from website/src/lib/sanity-draft.ts that returns the correct client for the current request and build target.
export async function getRouteClient(cookies: AstroCookies) {
if (import.meta.env.BUILD_TARGET === 'static') return sanity
return getSanityClient(cookies)
}
export async function getSanityClient(cookies: AstroCookies) {
if (!isDraftMode(cookies)) return sanity
const {env} = await import('cloudflare:workers')
const token = env.SANITY_API_READ_TOKEN ?? import.meta.env.SANITY_API_READ_TOKEN
return token ? sanityPreview.withConfig({token}) : sanityPreview
}
getRouteClient(Astro.cookies) is what every content route's frontmatter calls. The decision tree:
- Static (public) build —
BUILD_TARGETis inlined to'static', so the draft branch (and its cookie read) is dead-code-eliminated. The route compiles down to the publishedsanityclient. No cookie access, nocloudflare:workersin the prerendered graph. - Server (staging) build — falls through to
getSanityClient, the cookie-aware picker.isDraftMode(cookies)checks for thesanity-preview-perspectivecookie; if present, it returnssanityPreviewwith the runtime token attached, otherwise the published client.
isDraftMode(cookies) simply tests cookies.has(perspectiveCookieName) (the constant from @sanity/preview-url-secret/constants).
A real example of the usage pattern, from website/src/pages/index.astro:
const data = (await getHomepage(await getRouteClient(Astro.cookies))) ?? EMPTY;
The cloudflare:workers import is dynamic on purpose
Inside getSanityClient, import('cloudflare:workers') is a dynamic import, not top-level. A top-level import would drag the Workers runtime module into the build graph of every route that imports this file — including the prerendered routes of the static build, which must stay token-free. The dynamic import only resolves inside the draft branch, which the static build never reaches. Don't reintroduce a top-level import {env} from 'cloudflare:workers' here. (See Two-build split.)
Query helpers take an optional client
Every query helper in website/src/sanity/queries/ accepts an optional client parameter that defaults to the published sanity client, so a route can thread in the draft-aware one. The pattern, from website/src/sanity/queries/homepage.ts:
export async function getHomepage(client: SanityClient = sanity): Promise<HomepageData> {
return client.fetch<HomepageData>(homepageQuery);
}
website/src/sanity/queries/page.ts is the same shape for slug-driven pages, plus a parameterized fetch and a slug enumerator for getStaticPaths:
export async function getPageBySlug(
slug: string,
client: SanityClient = sanity,
): Promise<PagePayload> {
return client.fetch<PagePayload>(pageBySlugQuery, {slug});
}
export async function getPageSlugs(
client: SanityClient = sanity,
): Promise<{slug: string}[]> {
return client.fetch<{slug: string}[]>(pageSlugsQuery);
}
pageSlugsQuery is *[_type == "page" && defined(slug.current)]{"slug": slug.current} — the published client already excludes drafts, mirroring the sitemap filter. The convention for small queries is to co-locate them with the component that uses them; promote shared ones to src/sanity/queries/.
The shared sectionProjection
Every page-builder document — homepage.sections[], page.sections[], service.sections[], portfolioPost.sections[], and both index singletons' sectionsBelow[] — fetches its sections through the same GROQ fragment, sectionProjection in website/src/sanity/queries/projections.ts. That single fragment is why every page builder behaves identically. See Page builder overview.
The fragment opens with a spread and then overrides specific fields per section _type:
...,
_type == "logoWallSection" => {
...,
"clients": clients[]->{_id, name, logo, url, industry, order}
},
_type == "testimonialsSection" => {
...,
"testimonials": testimonials[]->{
_id, quote, name, title, company, photo, companyLogo,
"relatedCaseStudy": relatedCaseStudy->{slug}
}
},
_type == "workSection" => {
...,
"caseStudies": caseStudies[]->{
_id, title, slug, industry, summary, headlineStat, cardColorway, coverImage,
"client": client->{name, logo, url}
}
},
The ... spread picks up every plain field on the section (including the universal hideSection, animate, anchor, and sectionLayout meta — no query change is ever needed when you add those). The _type == "X" => {…} clauses override only the reference fields that need dereferencing.
Conditional reference projections
Three section types carry references to other documents and must dereference them: logoWallSection (clients[]->), testimonialsSection (testimonials[]->), and workSection (caseStudies[]->). The _type == clauses above turn those references into the flat data the components consume.
Reference fallbacks happen at the page query, not in the fragment
When a reference array is empty, the page falls back to featured/recent docs. That fallback is wired in the page-level query, not in sectionProjection. homepageQuery and pageBySlugQuery both fetch three sibling lists alongside the sections:
"clients": ${featuredClientsProjection},
"testimonials": ${featuredTestimonialsProjection},
"caseStudies": ${recentCaseStudiesProjection}
featuredClientsProjection—*[_type == "client" && featured == true]ordered byorderthen creation, first 6.featuredTestimonialsProjection—*[_type == "testimonial" && featured == true]newest first, first 3.recentCaseStudiesProjection—*[_type == "caseStudy"]ordered, first 3.
These ride along on the payload (PagePayload / HomepageData) and are passed into <SectionRenderer /> as the clients / testimonials / caseStudies props so a reference-mode section with no manual picks falls back to them. See Section types.
Service and explorer sections fetch live
Two more _type clauses don't dereference picks but query documents directly:
_type == "serviceGridSection" => {
...,
"services": *[_type == "service" && !(_id in path("drafts.**"))] | order(coalesce(order, 999) asc, _createdAt asc){
_id, slug, title, eyebrow, summary
}
},
serviceGridSection auto-populates from published service docs sorted by order. The two explorer sections (heroExplorerSection, servicesExplorerSection) use a select() that dereferences the editor's services[] picks or, when empty, falls back to the first 6 explorerService docs by order — emitting the same panels shape either way, with per-context labels (heroLabel / accordionLabel, coalescing to title):
"panels": select(
count(services) > 0 => services[]->{
"label": coalesce(heroLabel, title), value, "description": heroDescription, "image": heroImage
},
*[_type == "explorerService" && !(_id in path("drafts.**"))] | order(coalesce(order, 999) asc, _createdAt asc)[0...6]{
"label": coalesce(heroLabel, title), value, "description": heroDescription, "image": heroImage
}
)
The !(_id in path("drafts.**")) guard keeps draft documents out of these direct queries even when the published client wouldn't filter them (relevant in preview).
The hideSection filter
A section can be hidden per-instance. This is filtered out at fetch time, not at render time — hidden sections never reach the wire or the renderer. Both homepageQuery and pageBySlugQuery apply the filter in the array projection:
sections[hideSection != true]{${sectionProjection}}
So there is nothing to do in component code for hideSection; the data simply isn't there. (hideSection is declared on SectionBase in website/src/sanity/types.ts and carried by the spread, but the page query removes the section before the spread ever runs.) See Per-section visibility.
Building image URLs
website/src/lib/image.ts wraps @sanity/image-url against the published client:
const builder = createImageUrlBuilder(sanity)
export function urlFor(source: SanityImage) {
return builder.image(source).auto('format').fit('max')
}
Call it with a chain: urlFor(image).width(800).format('webp').url(). For Sanity-sourced images the convention is a raw <img> with a hand-rolled srcset — Astro's <Image> reprocesses remote URLs and clobbers Sanity's transform pipeline, so reserve <Image> for local public/ assets. See Images & fonts.
ogImageUrl(source) is the companion for og:image — a fixed 1200×630 crop, returning undefined when there's no asset:
export function ogImageUrl(source: SanityImage | undefined): string | undefined {
if (!source?.asset) return undefined
return builder.image(source).width(1200).height(630).fit('crop').format('jpg').url()
}
urlFor() throws on an asset-less image — guard on ?.asset, never the object
A Sanity image field is not safe to truthy-check. Removing the uploaded asset while leaving any sub-field set (e.g. alt) leaves a ghost object {_type:'image', alt:'…'} — truthy, but with no asset. Calling .url() on it throws, and an unhandled throw in a section's frontmatter/template blanks the entire page (the SSR stream is discarded after the doctype, and the Cloudflare dev adapter swallows the trace, so it fails silently).
Always guard on the asset, never on the object:
// ❌ throws in production if the asset was cleared
{img ? <img src={urlFor(img).url()} /> : null}
// ✅ safe
{img?.asset ? <img src={urlFor(img).url()} /> : null}
More broadly: treat every Sanity value as nullable at read time, even Studio-required() ones — coerce arrays with ?? [] (Sanity returns null, not undefined, for empty arrays), .filter(Boolean) dereferenced reference arrays, and optional-chain nested access. See Accessibility and Best practices for the full guard set.
The SanityImage shape (in website/src/sanity/types.ts) makes the trap explicit — asset itself is optional:
export interface SanityImage {
_type?: 'image';
asset?: {
_ref?: string;
_type?: 'reference';
url?: string;
metadata?: {dimensions?: {width: number; height: number}; lqip?: string};
};
alt?: string;
hotspot?: {x: number; y: number; height: number; width: number};
crop?: {top: number; bottom: number; left: number; right: number};
}
Other read paths
A couple of fetches deliberately sidestep the route-client pattern because they're genuinely site-wide:
- Theme tokens —
website/src/sanity/queries/globalStyles.tsfetches theglobalStylessingleton on every SSR request fromLayout.astro. It always uses the publishedsanityclient, never the preview client: stega injects zero-width characters into strings, which would corrupt CSS color values. A per-isolate memo (TTL_MS = 60_000) caches the result, but only for the published client —if (client !== sanity) return client.fetch(...)so draft clients bypass the cache. See Brand tokens & theme. - PSI scores and the sitemap follow the same "always published, never throw" rule for the same SSR-blanking and stega reasons.
Stega breaks string equality — clean before any logic
The preview client encodes string fields with invisible Unicode tag characters. They're invisible but not zero-length, so they break exact comparisons, switch, regex, JSON.parse, and object/array index keys. Any Sanity string used in JS logic must be passed through stegaClean() from @sanity/client/stega first, or code that works in production silently fails in draft preview. Rendering a plain string ({post.title}) is fine — only clean when the value participates in logic. The full treatment is on Presentation Tool & draft mode.
Where this lives
| File | What it holds |
|---|---|
website/src/lib/sanity.ts | sanity (published) + sanityPreview (stega) clients; pinned apiVersion |
website/src/lib/sanity-draft.ts | getRouteClient / getSanityClient / isDraftMode — cookie- and build-target-aware client picker |
website/src/lib/image.ts | urlFor() image builder + ogImageUrl(); the ?.asset throw contract |
website/src/sanity/queries/projections.ts | sectionProjection, siteSettingsProjection, featured*/recent* fallback projections |
website/src/sanity/queries/homepage.ts | homepageQuery + getHomepage() |
website/src/sanity/queries/page.ts | pageBySlugQuery / getPageBySlug() + pageSlugsQuery / getPageSlugs() |
website/src/sanity/queries/globalStyles.ts | getglobalStyles() — memoized, published-only theme-token fetch |
website/src/sanity/types.ts | hand-written types incl. SanityImage, SectionData union, payload shapes |
website/CLAUDE.md | the authoritative read-client + image-guard conventions |