How I Handle Conditional GROQ Projections to Cut Query Payload by 60%

May 02, 2026 · 5 min read

sanitygroqnext.jsperformancersc

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 hydrates into React state and gets dropped.

I started profiling RSC payload sizes after a client complained about slow 3G render on their product landing page. The page used a flexible content[] array of blocks—hero, features, testimonials, pricing—but each page only used three or four. The GROQ query projected all block types, pulling in image assets, portable text, and nested references the page never displayed.

Switching to conditional projections cut the JSON from 52 kB to 19 kB and shaved 180 ms off Time to First Byte on Vercel edge.

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 }
    }
  }
}

If the page only uses hero and ctaBlock, you're still fetching featureGrid, testimonialCarousel, and pricingTable schemas. Each brings nulls or empty arrays, but the shape is serialised.

Conditional projections with select

GROQ's select lets you return different shapes based on _type. 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: { slug: string } }) {
  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: params.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:

  1. A page or component has a union array (multiple block types), but only renders a subset per route.
  2. The unused blocks have heavy nested references (image assets, author documents, related posts).
  3. 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: testimonials[]->select(defined(featured) => { quote, author }, { quote }).

Benchmarks

Before conditional projections, my client's /solutions page fetched 52 kB of JSON in 280 ms (Singapore edge, warm cache). After:

  • Payload: 19 kB (63% reduction)
  • TTFB: 100 ms
  • LCP improved from 1.8s to 1.2s on 3G

The pattern composes. On a case-study index page with 30 items, I conditionally project { title, slug, excerpt } and skip the full portable text body. That alone cut 140 kB.

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.

Related posts

All posts →