
How to Dereference Documents in GROQ
To dereference documents in GROQ, add the -> operator after a reference field. A reference is stored as an object with a _ref ID, and -> replaces it with the document it points to, so author->name returns the author's name and author->{ name, image } returns a shaped object. For arrays of references, use categories[]-> to resolve each item. To go the other way and find documents that point at the current one, use a sub-query with references(^._id).
References are how Sanity connects documents, such as a post to its author or a product to its categories. They keep data in one place, so updating an author's bio updates every post at once. But a reference on its own is just an ID, and your front end needs the actual content. This guide explains what a reference looks like in the Content Lake, how -> resolves single references, arrays and nested references, how to write reverse lookups, how dereferencing behaves with drafts and missing documents, and how to keep joins fast.
What a Reference Looks Like
Suppose your schema has a post with an author reference and an array of category references, as described in how to use references in Sanity schemas:
// schemaTypes/post.ts
import { defineArrayMember, defineField, defineType } from 'sanity'
export const post = defineType({
name: 'post',
title: 'Post',
type: 'document',
fields: [
defineField({ name: 'title', type: 'string' }),
defineField({ name: 'slug', type: 'slug', options: { source: 'title' } }),
defineField({
name: 'author',
type: 'reference',
to: [{ type: 'author' }],
}),
defineField({
name: 'categories',
type: 'array',
of: [defineArrayMember({ type: 'reference', to: [{ type: 'category' }] })],
}),
],
})
A query without dereferencing returns the stored data:
*[_type == "post"][0] { title, author, categories }
{
"title": "Hello GROQ",
"author": { "_type": "reference", "_ref": "author-sajjad" },
"categories": [
{ "_key": "k1", "_type": "reference", "_ref": "cat-sanity" },
{ "_key": "k2", "_type": "reference", "_ref": "cat-groq" }
]
}
The _ref is the _id of the target document. Everything else about dereferencing builds on that.
Dereferencing a Single Reference
Add -> to replace the reference with the full referenced document:
*[_type == "post"][0] {
title,
author->
}
That returns every field of the author, which is rarely what you want. Usually you'll pick one value or project a few fields.
Getting One Field
*[_type == "post"] {
title,
"authorName": author->name,
"authorSlug": author->slug.current
}
Projecting Several Fields
*[_type == "post"] {
title,
author->{
name,
"slug": slug.current,
"avatar": image.asset->url
}
}
author->{ ... } keeps the key author and replaces its value with the projected object. Notice the second -> inside: image.asset is itself a reference, to the image asset document, and ->url resolves it to the CDN URL. Images and files are references to asset documents, so you'll use this pattern constantly. The guide to handling images in Sanity covers assets in more depth.
Dereferencing Arrays of References
For an array, add [] before the arrow:
*[_type == "post"] {
title,
"categories": categories[]->title
}
{ "title": "Hello GROQ", "categories": ["Sanity", "GROQ"] }
Or with a projection for each item:
*[_type == "post"] {
title,
"categories": categories[]->{ _id, title, "slug": slug.current }
}
Forgetting the [] is the most common dereferencing mistake. categories->title tries to dereference the array itself, which isn't a reference, so it returns null.
Mixed Arrays
Page builders often have arrays that mix inline objects and references. A conditional projection handles both:
*[_type == "page" && slug.current == $slug][0] {
title,
"sections": sections[] {
_key,
_type,
_type == "reference" => @->{ _type, title, "slug": slug.current },
_type != "reference" => @
}
}
Here @ is the current array item. If it's a reference, it's resolved and projected. Otherwise the inline object is returned as it is. The guide to projections explains conditional projections in more detail.
Chaining References
References can point to documents that have their own references. Chain arrows to follow them:
*[_type == "post"] {
title,
"authorCompany": author->company->name,
"teamLead": author->team->lead->name
}
Each -> is another lookup, so chain only as deep as you need. If you find yourself three or four levels deep regularly, that's a sign the content model might benefit from a flatter structure. The article on content modelling best practices discusses when to reference and when to embed.
Dereferencing in Filters
You can use -> inside a filter to match on a field of the referenced document:
*[_type == "post" && author->slug.current == $authorSlug]
*[_type == "post" && "groq" in categories[]->slug.current]
This is convenient, but it has to resolve the reference for every candidate post. If you already know the target's _id, references() is cheaper because it uses an index:
*[_type == "post" && references($authorId)]
A common pattern is to look up the ID first, then use it:
{
"author": *[_type == "author" && slug.current == $authorSlug][0] { _id, name }
} {
author,
"posts": *[_type == "post" && references(^.author._id)] | order(publishedAt desc) {
title, "slug": slug.current
}
}
The first object finds the author, and the second projection uses ^ to reach it from the sub-query.
Reverse References With references()
-> only goes one way: from the document holding the reference to its target. To find everything that points at a document, use a sub-query with references():
*[_type == "author" && slug.current == $slug][0] {
name,
bio,
"posts": *[_type == "post" && references(^._id)] | order(publishedAt desc) [0...10] {
title,
"slug": slug.current,
publishedAt
}
}
^ refers to the document one level up, here the author. references() returns true if the document contains a reference to that ID anywhere, at any depth, so it catches references inside arrays and nested objects too.
Be specific about the type in reverse lookups. Without _type == "post", the query would also return any other document that happens to reference the author, such as a team page.
| Direction | Syntax | Example use |
|---|---|---|
| Forward, single | field-> | Post to author |
| Forward, array | field[]-> | Post to categories |
| Forward, chained | a->b->c | Post to author's company |
| Reverse | *[references(^._id)] | Author to posts |
| Reverse, counted | count(*[references(^._id)]) | Number of posts per category |
How Dereferencing Handles Drafts and Missing Documents
A few behaviours are worth knowing before they surprise you:
- Missing targets return null: if the referenced document has been deleted (possible with weak references) or doesn't exist,
->returnsnull. Guard against it in your components. - Perspectives apply to joins: the perspective you query with affects which version of the referenced document you get. With the
publishedperspective, which is the default in current API versions, you get the published version. With thedraftsperspective, used for previews, you get the draft if one exists. - Unpublished targets: with the
publishedperspective, a reference to a document that has never been published resolves tonull, because there's no published version to return. - Strong references block deletion: by default, the Studio won't let you delete a document that others reference strongly, which keeps most joins intact. Set
weak: trueon a reference field if you need to allow it.
Here's a query that tolerates missing data:
*[_type == "post"] {
title,
"authorName": coalesce(author->name, "Unknown author"),
"categories": array::compact(categories[]->title)
}
coalesce() supplies a fallback and array::compact() removes null entries left by missing categories.
Using Dereferenced Data in Next.js
Here's a post page that resolves the author and categories, and lists related posts in the same request:
// src/sanity/queries.ts
import { defineQuery } from 'next-sanity'
export const POST_QUERY = defineQuery(`
*[_type == "post" && slug.current == $slug][0] {
_id,
title,
body,
author->{ name, "slug": slug.current, "avatar": image.asset->url },
"categories": categories[]->{ _id, title, "slug": slug.current },
"related": *[
_type == "post"
&& _id != ^._id
&& count(categories[@._ref in ^.^.categories[]._ref]) > 0
] | order(publishedAt desc) [0...3] { _id, title, "slug": slug.current }
}
`)
// src/app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation'
import { client } from '@/sanity/client'
import { POST_QUERY } from '@/sanity/queries'
export default async function PostPage({
params,
}: {
params: Promise<{ slug: string }>
}) {
const { slug } = await params
const post = await client.fetch(POST_QUERY, { slug })
if (!post) notFound()
return (
<article>
<h1>{post.title}</h1>
{post.author && <p>By {post.author.name}</p>}
<ul>
{post.categories?.map((category) => (
<li key={category._id}>{category.title}</li>
))}
</ul>
<h2>Related posts</h2>
<ul>
{post.related.map((item) => (
<li key={item._id}>
<a href={`/blog/${item.slug}`}>{item.title}</a>
</li>
))}
</ul>
</article>
)
}
The related-posts sub-query uses ^.^ to reach two levels up: inside categories[...], ^ is the candidate post, and ^.^ is the post being displayed. It's a dense line, so test it in the Vision plugin with a real slug before relying on it.
Performance Tips
- Project after dereferencing:
author->{ name }transfers far less thanauthor->. - Prefer references() over -> in filters when you know the ID.
- Limit reverse lookups: slice sub-queries like
[0...10]and avoid running them on long outer lists. - Avoid deep chains in hot paths: each level is another lookup.
- Consider GraphQL only if you need it: GROQ joins are one of its strengths, as covered in GROQ vs GraphQL.
FAQ: Dereferencing in GROQ
The arrow operator follows a reference. It takes the _ref ID stored in a reference field and replaces it with the document that has that _id, so you can read the referenced document's fields in the same query.
Add square brackets before the arrow, such as categories[]->title or categories[]-> followed by a projection. Without the brackets, GROQ tries to dereference the array itself and returns null.
Use the references() function in a filter, for example *[_type == "post" && references($authorId)]. Inside a projection, use references(^._id) to find documents that point at the document being projected.
Common causes are a missing pair of square brackets on an array, a deleted target document, a target that has never been published when you query with the published perspective, or a typo in the field name. Check the raw _ref value first, then query that ID directly.
A single level of dereferencing with a projection is fast and is the normal way to query Sanity. Costs add up with deep chains, reverse lookups on long lists and arrow operators inside filters, so slice sub-queries and use references() where you can.
Yes. Image and file fields store a reference to an asset document in the asset field, so image.asset->url returns the CDN URL and image.asset->metadata returns dimensions, colour palette and other metadata.
Conclusion
Dereferencing turns Sanity's references into usable data. Use field-> for single references, field[]-> for arrays, chain arrows for nested relationships, and always follow a dereference with a projection so you only fetch what you need. For the reverse direction, a sub-query with references(^._id) finds every document that points back.
Handle the edge cases deliberately with coalesce() and array::compact(), remember that perspectives affect which version of a referenced document you get, and prefer references() over arrows in filters. With those habits, a single GROQ query can assemble a whole page, author, categories and related content included.


