How I write advanced GROQ queries with joins, coalesce, and array flattening in Sanity
Jun 07, 2026 · 6 min read
Advanced GROQ queries in Sanity go well beyond *[_type == "post"]. Once you're pulling real content graphs — references across multiple document types, optional fields, nested arrays — naive queries balloon payloads and hurt RSC render times. Here's how I handle the patterns that come up most in production.
Why payload size actually matters in groq advanced queries and joins in Sanity
Every byte GROQ returns travels from Sanity's CDN to your Next.js server, gets deserialized, and then (in RSC) is serialized again into the RSC payload sent to the browser. I instrument every query with console.time / console.timeEnd during development and log the stringified response size:
// lib/sanity/query-debug.ts
export async function timedFetch<T>(queryFn: () => Promise<T>, label: string): Promise<T> {
console.time(label);
const result = await queryFn();
const bytes = new TextEncoder().encode(JSON.stringify(result)).length;
console.timeEnd(label);
console.log(`[${label}] payload: ${(bytes / 1024).toFixed(1)} kB`);
return result;
}With that wrapper in place I can compare before/after on every query I refactor. Numbers below come from a real e-commerce content site with ~4 000 documents.
Inline joins: resolving references without separate round-trips
Sanity references are stored as {_ref: "doc-id"}. GROQ can dereference them inline using ->, avoiding the N+1 pattern you'd get by fetching references separately in TypeScript.
This query fetches blog posts and resolves their author and category references in one round-trip:
// Before — naive, 41 kB payload
*[_type == "post" && defined(slug)] {
_id,
title,
slug,
author,
category
}
// After — inline join with projection, 8.3 kB payload
*[_type == "post" && defined(slug)] | order(publishedAt desc) [0..19] {
_id,
title,
"slug": slug.current,
"author": author-> {
name,
"avatar": image.asset->url
},
"category": category-> {
title,
"slug": slug.current
},
publishedAt
}The naive version sent every field on every document including Portable Text blocks, image asset metadata, and internal Sanity fields. Projecting explicitly and dereferencing inline cut the payload from 41 kB to 8.3 kB — 80% reduction for a list of 20 posts.
For deeply nested references (e.g., a post → author → company), chain -> twice:
"company": author->company-> { name, website }Sanity resolves this in the same query; there's no extra HTTP call.
coalesce: graceful fallbacks across optional fields
coalesce() returns the first non-null value in its argument list. I use it constantly for multi-locale fallbacks and optional overrides.
// pages/[slug] query — 12 kB payload
*[_type == "page" && slug.current == $slug][0] {
_id,
// Prefer SEO title override; fall back to main title
"title": coalesce(seo.title, title),
// Prefer SEO description; fall back to excerpt; fall back to empty string
"description": coalesce(seo.description, excerpt, ""),
// Prefer hero image; fall back to author avatar
"heroImage": coalesce(
heroImage.asset->url,
author->image.asset->url
),
"publishedAt": coalesce(publishedAt, _createdAt)
}Without coalesce I'd handle these fallbacks in TypeScript with ?? chains — which works, but means sending both fields over the wire even when only one is needed. coalesce evaluates server-side so you get one field back, not two.
Array flattening with [] and array::unique
When documents contain arrays of references — a curated homepage with multiple section blocks each containing an array of post references — you end up with a nested array structure that's awkward to work with in React.
// sections[]->posts[]->slug gives you [[slug1,slug2],[slug3]] — nested
// Flatten with array notation
*[_type == "homepage"][0] {
"allPostSlugs": sections[].posts[]->slug.current
}Groq automatically flattens sections[].posts[] into a single array. Combine with array::unique to deduplicate:
*[_type == "homepage"][0] {
"featuredTags": array::unique(sections[].posts[]->tags[]->title)
}Payload comparison on a homepage with 6 sections × 8 posts × 4 tags each: nested structure was 9.1 kB after JSON serialization; flattened unique array was 0.4 kB.
Conditional projections based on document subtype
Some schemas use a _type discriminator inside an array — think a blocks array that can hold textBlock, videoBlock, or ctaBlock. You want different fields for each type without fetching everything.
// components/[slug] page query
*[_type == "landingPage" && slug.current == $slug][0] {
_id,
title,
"blocks": blocks[] {
_type,
_key,
// Only on textBlock
_type == "textBlock" => {
"content": body[]
},
// Only on videoBlock
_type == "videoBlock" => {
"playbackId": muxVideo.asset->playbackId,
caption
},
// Only on ctaBlock
_type == "ctaBlock" => {
heading,
"href": link.url,
label
}
}
}The _type == "videoBlock" => { ... } syntax is a GROQ conditional projection. Fields inside only appear on matching items — the response for a textBlock item won't include playbackId at all. On a landing page with 12 mixed blocks, this pattern reduced the blocks payload from 22 kB to 6.4 kB compared to projecting all fields on every block.
Combining everything: a realistic product page query
Here's a composite query I use on a product detail page that combines joins, coalesce, conditional projections, and flattening:
// groq/product-page.groq — measured at 14.2 kB for a product with 3 variants, 6 related
*[_type == "product" && slug.current == $slug][0] {
_id,
"title": coalesce(seo.title, name),
"description": coalesce(seo.description, shortDescription, ""),
"slug": slug.current,
"brand": brand-> { name, "logo": logo.asset->url },
"images": images[].asset-> {
_id,
url,
"lqip": metadata.lqip,
"dimensions": metadata.dimensions
},
"variants": variants[] {
_key,
sku,
price,
"label": coalesce(label, sku),
inStock
},
"relatedProducts": relatedProducts[0..5]-> {
_id,
name,
"slug": slug.current,
price,
"thumbnail": images[0].asset-> {
url,
"lqip": metadata.lqip
}
},
"allCategories": array::unique(categories[]->title)
}A naive *[_type == "product"][0] on this same document returns 89 kB — every variant history, all draft metadata, full Portable Text for the long description. The shaped query above returns 14.2 kB and contains exactly what the page component needs.
A note on GROQ vs client-side filtering
I've seen teams fetch broad queries and filter/transform in TypeScript. It works at small scale but means your server component waits for more bytes than necessary, and you're paying Sanity's CDN egress on data you throw away. Push all filtering, projection, and aggregation into GROQ — that's what it's for. The CDN cache key is the query + params, so a tighter query is also a more cacheable query.
The pattern I follow: write the TypeScript type first (what does the component actually need?), then write a GROQ query that produces exactly that shape. If you're using Sanity TypeGen, the generated types will enforce the match at compile time — a useful double-check that your projection isn't sneaking in extra fields.
Related posts
All posts →How I set up Sanity TypeGen for fully typed GROQ queries in TypeScript
May 13, 2026 · 6 min read
A step-by-step guide to Sanity TypeGen: CLI config, generating TypeScript types from GROQ queries, CI integration, and handling schema evolution gotchas.
How I use @next/bundle-analyzer to find and fix Next.js bundle size bloat
Jun 05, 2026 · 5 min read
A step-by-step walkthrough of Next.js bundle size analysis and reduction: finding large imports, fixing vendor chunks, and trimming icon libraries.
Sanity CMS + Next.js App Router: a practical getting started guide
May 11, 2026 · 6 min read
A step-by-step sanity cms nextjs tutorial for developers setting up schemas, GROQ queries, and Sanity Studio with the App Router for the first time.