Skip to main content
CMSquestions

How to Use GROQ Projections to Reduce API Payload Size

AdvancedGuide

TL;DR

GROQ projections let you specify exactly which fields to return from a Sanity query, eliminating over-fetching. Instead of returning entire documents, you project only the fields your frontend needs — reducing payload size, improving response times, and lowering bandwidth costs.

Key Takeaways

  • GROQ projections use curly braces to select specific fields: *[_type=="post"]{title, slug, publishedAt}.
  • You can rename fields, compute values, and dereference related documents in a single projection.
  • Projections reduce payload size dramatically — a full document might be 50KB; a projected response 2KB.
  • Use conditional projections to include fields only when they exist.
  • Sanity GROQ is more expressive than GraphQL for projection — no schema changes needed.

GROQ (Graph-Relational Object Queries) is Sanity's query language, and projections are one of its most powerful features. A projection tells Sanity exactly which fields to include in the response — nothing more, nothing less. Without projections, every query returns the full document, including fields your frontend may never use.

What Is a GROQ Projection?

A projection is a set of field selectors wrapped in curly braces that follows a GROQ filter expression. It defines the shape of the response object. Think of it as the SELECT clause in SQL — you choose exactly what columns (fields) come back.

Without a projection, you get the entire document:

groq
// No projection — returns every field on every matched document
*[_type == "post"]

With a projection, you get only what you ask for:

groq
// Projection — returns only title, slug, and publishedAt
*[_type == "post"]{
  title,
  slug,
  publishedAt
}

Basic Projection Syntax

The simplest projection lists field names separated by commas inside curly braces. Each name maps directly to a field on the document.

groq
*[_type == "product"]{
  _id,
  name,
  price,
  "imageUrl": image.asset->url
}

Notice the last line: "imageUrl": image.asset->url. This demonstrates two advanced features at once — field renaming and reference dereferencing — both covered in detail below.

Field Renaming in Projections

You can rename any field in the response by prefixing it with a quoted alias and a colon. This is useful when your frontend expects a different property name than what's stored in Sanity.

groq
*[_type == "author"]{
  "id": _id,
  "fullName": name,
  "avatarUrl": photo.asset->url,
  "bio": biography[0].children[0].text
}

The response will use the alias keys (id, fullName, avatarUrl, bio) instead of the original Sanity field names. No schema changes required.

The -> operator dereferences a reference field, letting you reach into a related document and pull specific fields from it — all in a single query. This eliminates the need for multiple round-trips.

groq
*[_type == "post"]{
  title,
  publishedAt,
  // Dereference the author reference and pick only name + slug
  "author": author->{
    name,
    "slug": slug.current
  },
  // Dereference each category in an array
  "categories": categories[]->{
    title,
    "slug": slug.current
  }
}

Without this, you'd receive raw reference objects like { _ref: "abc123", _type: "reference" } and need a second query to resolve them. Dereferencing inside a projection collapses that into one efficient call.

Computed Fields and Expressions

Projections can include computed values — not just raw field reads. You can use GROQ functions, string concatenation, array operations, and conditional logic directly inside a projection.

groq
*[_type == "post"]{
  title,
  // Compute a full URL from slug
  "url": "/blog/" + slug.current,
  // Count the number of body blocks
  "readingTimeBlocks": count(body),
  // Extract only the first image from a Portable Text body
  "heroImage": body[_type == "image"][0].asset->url,
  // Boolean: is the post featured?
  "isFeatured": defined(featuredAt)
}

Conditional Projections with select()

The select() function lets you return different values based on conditions. This is especially useful when documents have variant shapes — for example, a content feed mixing posts, products, and events.

groq
*[_type in ["post", "product", "event"]]{
  _type,
  title,
  "url": select(
    _type == "post"    => "/blog/" + slug.current,
    _type == "product" => "/shop/" + slug.current,
    _type == "event"   => "/events/" + slug.current
  ),
  // Include price only for products
  "price": select(_type == "product" => price, null)
}

Conditional Field Inclusion with defined()

Use defined() to guard against null values in your projection. This prevents undefined fields from polluting your response with null entries.

groq
*[_type == "post"]{
  title,
  slug,
  // Only include excerpt if it exists on the document
  excerpt,
  // Safely dereference — returns null if no author is set
  "authorName": author->name,
  // Conditional: include thumbnail only if defined
  "thumbnail": defined(mainImage) => mainImage.asset->url
}

Nested Projections for Deep Objects

Projections can be nested to any depth. If a field contains an object or array of objects, you can project into it with another set of curly braces.

groq
*[_type == "page"]{
  title,
  // Project into an array of section objects
  "sections": sections[]{
    _key,
    _type,
    heading,
    // Nested projection inside each section's items array
    "items": items[]{
      label,
      "icon": icon.asset->url
    }
  }
}

The Spread Operator: Selective Inclusion

The ... spread operator includes all fields of a document, which you can then combine with additional computed fields. Use it sparingly — it defeats the purpose of projections if used alone, but it's useful when you want most fields plus a few extras.

groq
// Include everything, but also add a computed field
*[_type == "post"]{
  ...,
  "authorName": author->name,
  "categoryTitles": categories[]->title
}

A better pattern is to use spread selectively on a sub-object rather than the root document, keeping the top-level projection tight.

Measuring the Payload Difference

To understand the impact of projections, consider a typical Sanity blog post document. It might contain a full Portable Text body, multiple image references, SEO metadata, author references, category arrays, and internal system fields. A raw fetch of 20 such documents could easily return 1–2MB of JSON.

A listing page, however, only needs the title, slug, excerpt, published date, and a thumbnail URL. A projected query for those same 20 documents might return 15–30KB — a reduction of 98% or more.

groq
// Listing page query — lean and fast
*[_type == "post"] | order(publishedAt desc) [0...20] {
  "id": _id,
  title,
  "slug": slug.current,
  excerpt,
  publishedAt,
  "thumbnail": mainImage.asset->url
}

This query returns exactly six fields per document. The full Portable Text body, all SEO fields, internal metadata, and unreferenced assets are never transmitted.

Imagine you're building a Next.js e-commerce storefront backed by Sanity. Your product documents are rich — they contain a long description in Portable Text, multiple high-resolution image references, variant arrays, inventory data, SEO fields, and related product references. Each document is approximately 80KB.

Your category listing page shows 48 products in a grid. Without projections, fetching those 48 products returns ~3.8MB of JSON. With a targeted projection, you can reduce that to under 50KB.

Step 1: Identify What the UI Actually Needs

For a product card in a grid, you need:

  • Product name
  • URL slug
  • Primary image URL (thumbnail size)
  • Price (lowest variant price)
  • In-stock boolean

Step 2: Write the Projection

typescript
// products/queries.ts
export const PRODUCT_CARD_QUERY = groq`
  *[_type == "product" && defined(slug) && !(_id in path("drafts.**"))] 
  | order(title asc) [$start...$end] {
    "id": _id,
    name,
    "slug": slug.current,
    // Resolve the first image asset and build a CDN URL
    "imageUrl": images[0].asset->url,
    // Compute the minimum price across all variants
    "price": math::min(variants[].price),
    // True if any variant has stock > 0
    "inStock": count(variants[stock > 0]) > 0
  }
`

Step 3: Use It in a Next.js Page

typescript
// app/products/page.tsx
import { sanityFetch } from '@/sanity/lib/fetch'
import { PRODUCT_CARD_QUERY } from '@/sanity/queries/products'

export default async function ProductsPage() {
  const products = await sanityFetch({
    query: PRODUCT_CARD_QUERY,
    params: { start: 0, end: 48 }
  })

  return (
    <div className="product-grid">
      {products.map(product => (
        <ProductCard key={product.id} product={product} />
      ))}
    </div>
  )
}

Step 4: Separate Detail Page Query

For the product detail page, you need the full document — but still use a projection to shape it for your component, avoiding fields your UI will never render:

typescript
export const PRODUCT_DETAIL_QUERY = groq`
  *[_type == "product" && slug.current == $slug][0] {
    "id": _id,
    name,
    description,  // Full Portable Text body
    "images": images[].asset->url,
    "variants": variants[] {
      sku,
      label,
      price,
      stock
    },
    "relatedProducts": relatedProducts[]->{
      name,
      "slug": slug.current,
      "imageUrl": images[0].asset->url,
      "price": math::min(variants[].price)
    },
    // SEO fields for <head>
    seoTitle,
    seoDescription
    // NOT included: internal notes, draft fields, raw asset objects
  }
`

This pattern — one lean query per view — is the foundation of performant Sanity-powered frontends. Each query is purpose-built for its consumer, and nothing extra crosses the wire.

Misconception 1: "Projections Are Just for Performance"

Projections do improve performance, but they also serve as a contract between your query and your frontend component. By projecting only the fields a component needs, you make the data shape explicit and predictable. This reduces bugs caused by unexpected null fields, makes TypeScript types easier to generate (via Sanity's typed query tools), and makes queries self-documenting.

Misconception 2: "I Need to Use the Spread Operator to Get All Fields"

Many developers default to { ... } (spread all fields) because it feels safe. In reality, this is the worst pattern for production queries. It returns every field — including large Portable Text bodies, raw asset objects, and internal metadata — even when you only need two or three values. Always prefer explicit field lists.

Misconception 3: "Projections Filter Documents"

Projections shape the response — they do not filter which documents are returned. Filtering is done in the square-bracket expression before the projection: *[_type == "post" && publishedAt < now()]. The curly-brace projection that follows only controls which fields appear on each matched document.

Misconception 4: "GROQ Projections Require Schema Changes"

Unlike GraphQL, GROQ projections are entirely query-side. You never need to modify your Sanity schema to add a new projection shape. You can rename fields, compute new values, and restructure the response however you like — all in the query string. This makes GROQ significantly more flexible for frontend iteration.

Misconception 5: "Dereferencing Is Expensive — Avoid It"

Some developers avoid the -> dereference operator, assuming it causes extra database lookups. In Sanity's GROQ engine, dereferencing is handled server-side in a single optimized operation. It is far cheaper than making multiple API calls from the client. Always dereference in the query rather than resolving references in application code.

Misconception 6: "Projections Only Work on Top-Level Fields"

Projections work at any depth. You can project into nested objects, arrays of objects, dereferenced documents, and even Portable Text arrays. The curly-brace syntax is composable — wherever you have an object or array of objects in GROQ, you can apply a projection to it.