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 but useCdn: false, perspective: 'drafts', and stega: {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) buildBUILD_TARGET is inlined to 'static', so the draft branch (and its cookie read) is dead-code-eliminated. The route compiles down to the published sanity client. No cookie access, no cloudflare:workers in the prerendered graph.
  • Server (staging) build — falls through to getSanityClient, the cookie-aware picker. isDraftMode(cookies) checks for the sanity-preview-perspective cookie; if present, it returns sanityPreview with 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 by order then 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 tokenswebsite/src/sanity/queries/globalStyles.ts fetches the globalStyles singleton on every SSR request from Layout.astro. It always uses the published sanity client, 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

FileWhat it holds
website/src/lib/sanity.tssanity (published) + sanityPreview (stega) clients; pinned apiVersion
website/src/lib/sanity-draft.tsgetRouteClient / getSanityClient / isDraftMode — cookie- and build-target-aware client picker
website/src/lib/image.tsurlFor() image builder + ogImageUrl(); the ?.asset throw contract
website/src/sanity/queries/projections.tssectionProjection, siteSettingsProjection, featured*/recent* fallback projections
website/src/sanity/queries/homepage.tshomepageQuery + getHomepage()
website/src/sanity/queries/page.tspageBySlugQuery / getPageBySlug() + pageSlugsQuery / getPageSlugs()
website/src/sanity/queries/globalStyles.tsgetglobalStyles() — memoized, published-only theme-token fetch
website/src/sanity/types.tshand-written types incl. SanityImage, SectionData union, payload shapes
website/CLAUDE.mdthe authoritative read-client + image-guard conventions
Previous
Studio & singletons