GROQ Conditional Projections: Cut Query Payload by 60%
By Nayan Kyada · · 5 min read
Part of The Sanity + Next.js Guide
Most Sanity queries return the entire document shape. For a typical marketing page with ten portable-text blocks, a hero image, and a CTA grid, you might fetch 40 kB of JSON when the component renders 12 kB. The rest is wasted bandwidth and parsing, and if you pass it to Client Components it also gets serialised into the RSC payload.
A typical case: a product landing page built from a flexible content[] array of blocks—hero, features, testimonials, pricing—where each page only uses three or four. The GROQ query projects all block types, pulling in image assets, portable text, and nested references the page never displays.
In the illustrative example below, switching to conditional projections takes the JSON from 52 kB to 19 kB.
The naive approach
Here's the schema. A page document with a content array that accepts multiple block types:
// schemas/page.ts
import { defineType, defineArrayMember } from 'sanity'
export default defineType({
name: 'page',
type: 'document',
fields: [
{
name: 'content',
type: 'array',
of: [
defineArrayMember({ type: 'hero' }),
defineArrayMember({ type: 'featureGrid' }),
defineArrayMember({ type: 'testimonialCarousel' }),
defineArrayMember({ type: 'pricingTable' }),
defineArrayMember({ type: 'ctaBlock' }),
],
},
],
})The default query expands every block:
*[_type == "page" && slug.current == $slug][0] {
_id,
title,
content[] {
_type,
_key,
// hero
_type == "hero" => {
headline,
image { asset->{ url, metadata } },
cta { text, url }
},
// featureGrid
_type == "featureGrid" => {
items[]{ title, description, icon }
},
// testimonialCarousel
_type == "testimonialCarousel" => {
testimonials[]->{ quote, author, role, company }
},
// pricingTable
_type == "pricingTable" => {
tiers[]{ name, price, features[] }
},
// ctaBlock
_type == "ctaBlock" => {
heading,
buttons[]{ text, url, variant }
}
}
}The => conditionals only merge fields when the type matches, so you don't get nulls for other types. But every block in the array is still returned, so if this route only renders hero and ctaBlock, any featureGrid, testimonialCarousel, or pricingTable blocks on the document still come back with their expanded references.
Conditional projections with select
GROQ's select lets you return different shapes based on _type. Spread its result into the projection with ... (an unnamed function call isn't a valid projection entry). Instead of expanding all blocks, you project only the fields each block needs:
*[_type == "page" && slug.current == $slug][0] {
_id,
title,
content[] {
_type,
_key,
...select(
_type == "hero" => {
headline,
"image": image.asset->url,
"imageAlt": image.alt,
"ctaText": cta.text,
"ctaUrl": cta.url
},
_type == "featureGrid" => {
items[]{ title, description, icon }
},
_type == "testimonialCarousel" => {
testimonials[]->{ quote, author, role }
},
_type == "pricingTable" => {
tiers[]{ name, price, features }
},
_type == "ctaBlock" => {
heading,
buttons[]{ text, url, variant }
}
)
}
}This still expands all types, but you control the depth. For a real win, combine it with a type filter at query time. If your component knows it only renders hero and ctaBlock, pass an array of allowed types:
// app/[slug]/page.tsx
import { client } from '@/sanity/client'
import { PageBlocks } from '@/components/PageBlocks'
const ALLOWED_BLOCKS = ['hero', 'ctaBlock'] as const
export default async function Page({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params
const query = `
*[_type == "page" && slug.current == $slug][0] {
_id,
title,
"content": content[_type in $allowedTypes]{
_type,
_key,
...select(
_type == "hero" => { headline, "image": image.asset->url, "ctaText": cta.text },
_type == "ctaBlock" => { heading, buttons[]{ text, url } }
)
}
}
`
const page = await client.fetch(query, {
slug,
allowedTypes: ALLOWED_BLOCKS,
})
return <PageBlocks blocks={page.content} />
}The content[_type in $allowedTypes] filter removes blocks that don't match. The select then projects only the fields those two types need. Result: 19 kB instead of 52 kB.
When to use this pattern
I apply conditional projections when:
- A page or component has a union array (multiple block types), but only renders a subset per route.
- The unused blocks have heavy nested references (image assets, author documents, related posts).
- The route is high-traffic and edge-cached, so every byte counts toward TTFB and LCP.
I skip it when the content array is small (under 10 kB) or the page uses all block types anyway. The added GROQ complexity isn't worth it for marginal gains.
Gotchas
TypeScript shape drift. If you project { headline, image } but your component expects { headline, image, subheading }, TypeScript won't catch it unless you generate types from the query. I use Sanity TypeGen for this, running sanity typegen generate after every schema change.
Image metadata loss. Projecting image.asset->url gives you a string, not an object. If you need lqip or dimensions, expand the projection: image { "url": asset->url, "lqip": asset->metadata.lqip }.
Reference depth. select works inside nested arrays, but if you have testimonials[]->{ author->{ bio, avatar } }, you're still fetching bio and avatar for every testimonial. Use another select inside the reference projection: testimonials[]->{ ...select(defined(featured) => { quote, author }, { quote }) }.
Benchmarks
In the example above, the payload drops from 52 kB to 19 kB (a 63% reduction). How much that moves TTFB or LCP depends on your hosting, caching, and how much of the data reaches Client Components, so measure your own routes before and after.
The pattern composes. On an index page listing many items, project only { title, slug, excerpt } and skip the full portable text body; on long-form content that is often the biggest saving of all.
I now default to conditional projections for any union array with more than three block types. The GROQ gets longer, but the runtime payload and edge cache hit rate improve enough to justify it.
Frequently asked questions
01What's the difference between select() and a conditional _type == "x" => projection in GROQ?
`select(cond => value, ...)` is an expression that evaluates branches in order and returns the first match — useful as a value anywhere in a projection. The `_type == "x" => { ... }` form merges an object's fields into the parent projection only when the condition is true. This post combines both: `select()` to branch by block type, with object-merge syntax inside each branch.
02How do I filter a Sanity array to only the block types a page component actually renders?
Use an `in` filter directly in the array projection: `content[_type in $allowedTypes]{ ... }`, passing the component's known block types as a GROQ parameter. Combined with `select()` for field-level projection per type, this is what took the example query from 52 kB down to 19 kB. For more patterns like this, see [advanced GROQ queries with joins, coalesce, and array flattening](/blog/advanced-groq-queries-joins-coalesce-and-array-flattening-in-sanity).