
Using Projections in GROQ Queries
A projection in GROQ is the part of a query inside curly braces that decides what each result looks like. Instead of returning whole documents, *[_type == "post"]{ title, "slug": slug.current } returns objects with only a title and a flattened slug. Projections let you pick fields, rename them, compute new values with expressions and functions, follow references with ->, include everything with ..., and add fields conditionally with condition => { ... }.
Projections are where GROQ stops being a filter language and becomes a way to design your API response. A good projection sends your front end exactly the shape it renders, with no extra fields, no client-side reshaping and no second request for related data. This guide covers the basic syntax, renaming and computed fields, the spread operator, nested and array projections, conditional projections for mixed content, and how projections fit with TypeScript in a Next.js project.
Why Projections Matter
Without a projection, GROQ returns every stored field, including system fields like _rev and _updatedAt, full image objects with crop and hotspot data, and long Portable Text bodies you might not render on a listing page.
| Without a projection | With a projection |
|---|---|
| Every field, including unused ones | Only the fields you name |
References as _ref IDs | Resolved data from the referenced document |
| Nested objects as stored | Flattened or renamed to suit your UI |
| Larger responses, slower pages | Smaller payloads, simpler components |
| Types tied to your schema | Types tied to what the page uses |
There's also a maintenance benefit. When your schema changes, a projection acts as a contract: you can rename a field in the schema and keep the old key in the projection until the front end catches up.
Basic Syntax
A projection follows a filter (or any expression that returns objects):
*[_type == "post"] {
_id,
title,
publishedAt
}
Each result now has three keys. Fields that don't exist on a document come back as null rather than being left out, so your code can rely on the keys being present.
[
{ "_id": "a1", "title": "Hello GROQ", "publishedAt": "2026-11-01T09:00:00Z" },
{ "_id": "b2", "title": "Draft idea", "publishedAt": null }
]
Projections work on single documents too:
*[_type == "post" && slug.current == $slug][0] { title, body }
Renaming and Computing Fields
Any key in quotes followed by a colon creates a new field from an expression:
*[_type == "post"] {
title,
"slug": slug.current,
"date": publishedAt,
"year": string::split(publishedAt, "-")[0],
"readingTime": round(length(pt::text(body)) / 5 / 200),
"tagCount": count(tags),
"subtitle": coalesce(subtitle, excerpt, "No summary yet")
}
What's happening here:
- "slug": slug.current flattens the slug object to a plain string, which is what you'll use in URLs.
- "date": publishedAt renames a field to suit your component's props.
- "year" splits the ISO date string and takes the first part.
- "readingTime" estimates minutes from the plain text of a Portable Text body, using roughly five characters per word and 200 words per minute.
- "tagCount" counts an array.
- "subtitle" uses
coalesce()to fall back through several fields.
The GROQ functions guide lists the functions you can use in expressions like these.
Constant Values
You can also add literal values, which is handy when you merge results from several queries and need to know where each came from:
*[_type == "post"] { title, "kind": "article" }
The Spread Operator
... includes every field of the current document. Add your own keys after it to extend or override:
*[_type == "post"] {
...,
"slug": slug.current,
"author": author->{ name, "image": image.asset->url }
}
Here the document keeps all of its fields, but slug is replaced with the string and author is replaced with the resolved author. Keys listed after ... win over the original fields.
Use the spread operator sparingly in production queries. It's convenient while prototyping, but it brings back everything, which defeats the purpose of a projection. A good compromise is to use ... inside small nested objects, such as Portable Text blocks, where you need the full structure anyway.
Nested Projections
You can project any object field, not just the top-level document:
*[_type == "post"] {
title,
seo { metaTitle, metaDescription },
"hero": mainImage {
alt,
"url": asset->url,
"dimensions": asset->metadata.dimensions { width, height }
}
}
seo { metaTitle, metaDescription } is shorthand for "seo": seo { metaTitle, metaDescription }. The hero key shows how to rename and reshape at the same time.
Projecting References
Following a reference with -> returns the full referenced document, so combine it with a projection to keep things lean:
*[_type == "post"] {
title,
author->{ name, "slug": slug.current },
"category": category->title
}
The first form, author->{ ... }, returns an object. The second, category->title, returns a single value. Dereferencing has its own guide: how to dereference documents in GROQ.
Projecting Arrays
Add [] to apply a projection to each item in an array:
*[_type == "post"] {
title,
"gallery": gallery[] {
_key,
alt,
caption,
"url": asset->url
},
"categories": categories[]->{ title, "slug": slug.current }
}
Writing field[] makes it explicit that you're working item by item, and it's required when you dereference: categories-> on an array of references returns null, while categories[]-> resolves each one. Make it a habit to write field[] whenever the field is an array. Keep _key in projected arrays if your React components need stable keys.
Filtering Inside a Projection
You can filter an array before projecting it:
*[_type == "product"] {
title,
"availableVariants": variants[stock > 0] { sku, colour, stock }
}
For deeper structures, see querying nested arrays in GROQ.
Sub-Queries in Projections
A projection can contain its own query. Use ^ to refer to the document being projected:
*[_type == "author"] {
name,
"postCount": count(*[_type == "post" && references(^._id)]),
"latestPosts": *[_type == "post" && references(^._id)]
| order(publishedAt desc) [0...3] { title, "slug": slug.current }
}
This is how you get reverse relationships, such as "posts by this author", in the same request. Sub-queries run for every result, so keep the outer list small or slice it before projecting.
Conditional Projections
Pages built from blocks, or arrays that accept several types, need different fields depending on the item. A conditional projection uses condition => { fields }:
*[_type == "page" && slug.current == $slug][0] {
title,
"sections": sections[] {
_key,
_type,
_type == "hero" => {
heading,
"image": image.asset->url
},
_type == "textBlock" => {
body
},
_type == "featuredPosts" => {
"posts": posts[]->{ title, "slug": slug.current }
}
}
}
Every section gets _key and _type, then only the fields that match its type. Your React code can switch on _type to pick a component.
Using select() for Single Values
When you need one value that depends on a condition, use select() instead:
*[_type == "product"] {
title,
"badge": select(
stock == 0 => "Sold out",
stock < 5 => "Low stock",
"In stock"
)
}
select() returns the value for the first true condition, with the last argument as a default.
Top-Level Object Projections
A query doesn't have to start with *. You can start with an object and fill each key with its own query:
{
"settings": *[_type == "siteSettings"][0] { title, description },
"posts": *[_type == "post"] | order(publishedAt desc) [0...5] { title, "slug": slug.current },
"total": count(*[_type == "post"])
}
One request returns everything a home page needs. This pairs well with a singleton settings document.
Projections and TypeScript
Because projections define the shape of your data, they're also what your types should be based on. Sanity TypeGen reads your schema and your queries and generates a result type for each one:
npx sanity schema extract
npx sanity typegen generate
Write your queries with defineQuery so TypeGen can find them:
// src/sanity/queries.ts
import { defineQuery } from 'next-sanity'
export const POST_CARD_QUERY = defineQuery(`
*[_type == "post" && defined(slug.current)] | order(publishedAt desc) [0...12] {
_id,
title,
"slug": slug.current,
"imageUrl": mainImage.asset->url,
"author": author->name
}
`)
// src/app/blog/page.tsx
import { client } from '@/sanity/client'
import { POST_CARD_QUERY } from '@/sanity/queries'
export default async function BlogPage() {
const posts = await client.fetch(POST_CARD_QUERY)
return (
<div className="grid gap-6 md:grid-cols-3">
{posts.map((post) => (
<a key={post._id} href={`/blog/${post.slug}`}>
{post.imageUrl && <img src={post.imageUrl} alt="" />}
<h2>{post.title}</h2>
<p>{post.author}</p>
</a>
))}
</div>
)
}
The generated type for posts includes slug: string | null and author: string | null, matching the projection rather than the raw schema. If you change the projection, regenerate and TypeScript points out every component that needs updating.
Best Practices
- Project on every production query: never ship
*[_type == "post"]without braces to a front end. - Shape for the component: name keys after your props, so components receive data they can use directly.
- Flatten slugs and image URLs:
"slug": slug.currentand"url": asset->urlremove a layer of nesting everywhere. - Use separate projections for list and detail views: a card doesn't need the body, so keep a lean card query and a full detail query.
- Keep
_idand_key: they're useful for React keys and for linking back to the Studio. - Prefer explicit fields over
...: spreads are handy while prototyping, but explicit fields keep payloads small and types precise.
FAQ: GROQ Projections
A projection is the object in curly braces at the end of a query that defines the shape of each result. It lists which fields to return, can rename them, and can compute new values from expressions, functions and references.
It includes every field of the current document. You can add keys after it to extend the result or override existing fields, such as replacing a reference with the resolved document. Use it sparingly in production queries because it returns everything.
Write the new name in double quotes, a colon and the original field or expression, for example "date": publishedAt. The result then has a date key with the value of publishedAt.
The most common cause is a missing pair of square brackets. For arrays of references, write field[]-> to resolve each one, because field-> on an array returns null. For other arrays, writing field[] before the projection makes the item-by-item intent explicit.
Yes, with a conditional projection. Inside the braces, write a condition such as _type == "hero" followed by an arrow and a nested projection. Only results where the condition is true get those extra fields.
No. If you name a field in a projection and the document doesn't have it, the key is still returned with a null value. That makes the shape predictable, but your code should handle nulls.
Conclusion
Projections are the most useful part of GROQ day to day. They let you pick, rename and compute fields, resolve references and reverse references, project arrays item by item and return different shapes for different block types, all in one request that matches what your page renders.
Write a projection for every query your front end uses, keep list and detail projections separate, and let TypeGen generate types from them. If you're building up your toolkit, the GROQ cheat sheet summarises the patterns from this guide alongside filtering, sorting and search.


