Sanity Content Relationships: Document References vs. Portable Text Embeds
Decide between Sanity document references and Portable Text embeds for content relationships: a comparison table, trade-offs, schema code, and GROQ validation.
07 Sept 2026, 14:41 UTC

Every Sanity project eventually hits the same schema question: when a post needs an author, a related product, or a pull-quote linking to another document, do you model that as a top-level reference field, or embed it inline inside Portable Text? The two mechanisms look similar in the Studio but behave very differently in queries, editorial workflow, and deletion safety. Picking the wrong one early means a painful content migration later.
This guide compares both supported options, shows the trade-offs, and walks through a concrete implementation you can verify with GROQ. Examples assume Sanity Studio v3 and the current GROQ specification; check your installed versions with npm list sanity @sanity/client.
The decision and its constraints
The decision is where the relationship lives:
- Document-level reference: a dedicated field of type
referenceon the document, pointing at one or more other documents. Best for structured, queryable relationships (post → author, product → category). - Portable Text embed: a custom object or annotation inside the rich-text body, holding a reference inline. Best for editorial, in-context links (a mention of another article mid-paragraph, an embedded product card inside a blog post).
The constraints that usually decide it: whether you need to query or filter by the relationship, whether editors must see and manage it as structured data, and whether the linked document should be protected from deletion.
Comparing the two options
| Criteria | Reference field | Portable Text embed |
|---|---|---|
| Query/filter by relationship | Easy: author->slug.current == $slug | Hard: references sit inside an array of blocks |
| Editorial placement | Fixed position in the layout | Editor controls exact position in the text |
| Deletion protection (strong reference) | Yes, by default | Yes, but easier to overlook inside long bodies |
| Rendering work | Plain field access | Custom component/annotation serializers |
| Good for | Authors, categories, related items, taxonomies | Inline mentions, embedded media/CTAs, footnote-style links |
Trade-offs that matter in practice
Strong references and deletion. By default, references are strong: Sanity blocks deletion of a document that is still referenced. That protects integrity, but with embeds scattered across hundreds of Portable Text bodies, retiring a legacy document means hunting down every inline link first. If you want to allow deletion anyway, you can mark a reference as weak (weak: true), which lets the target be deleted and leaves a dangling ID your frontend must tolerate.
Query cost. Resolving references uses the dereference operator ->. A single level (author->name) is cheap. Deep chains (author->org->logo.asset->url) or dereferencing inside every block of a large Portable Text array increase query complexity and response size. Fetch only the fields you need, and avoid dereferencing the same document repeatedly in one query.
Circular references. If posts reference related posts, and those reference back, naive recursive rendering on the frontend can loop. Resolve only a fixed depth in GROQ and treat the relationship as a graph, not a tree.
Implementation: a reference field plus an inline embed
A typical setup uses both: a structured author reference, and an inline article link annotation inside the body.
// schemas/author.js
export default {
name: 'author',
type: 'document',
title: 'Author',
fields: [
{ name: 'name', type: 'string', title: 'Name' },
{ name: 'slug', type: 'slug', title: 'Slug', options: { source: 'name' } },
],
}
// schemas/post.js
export default {
name: 'post',
type: 'document',
title: 'Post',
fields: [
{ name: 'title', type: 'string', title: 'Title' },
{
name: 'author',
type: 'reference',
title: 'Author',
to: [{ type: 'author' }],
},
{
name: 'body',
type: 'array',
title: 'Body',
of: [
{
type: 'block',
marks: {
annotations: [
{
name: 'internalLink',
type: 'object',
title: 'Internal link',
fields: [
{
name: 'reference',
type: 'reference',
title: 'Reference',
to: [{ type: 'post' }],
},
],
},
],
},
},
],
},
],
}Run this in your Studio project where your schema files live (no special permissions beyond your normal dev setup), then restart the Studio dev server with npm run dev. Create an author, then a post that references them, and add an internal link annotation to some text in the body.
Validating with GROQ
Open the Vision plugin in the Studio (or use sanity documents query from the CLI) and run:
*[_type == "post"][0]{
title,
"authorName": author->name,
"linkedPosts": body[].markDefs[]{
_type == "internalLink" => {
"linkedTitle": reference->title
}
}
}Expected checks: authorName is a string, not null — a null result means the reference is unset or the ID is dangling. linkedPosts should contain the titles of any inline-linked posts. If linkedTitle is null while an annotation exists, the annotation's reference is broken or the target was deleted (possible only with weak references).
Limitations
Filtering posts by a reference embedded in Portable Text is impractical in GROQ; if you need "all posts that mention product X," promote that to a document-level reference array. Also, dereferencing inside block arrays can produce large responses on long bodies — project only the fields you render. Finally, strong references inside Portable Text still block deletion, but the Studio's delete warning won't tell you where in the text the link lives, so expect some manual cleanup for legacy content.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.