Skip to content

References

A reference field points at another document. It stores a pointer, not a copy — so renaming a category renames it everywhere, and the pointer keeps working.

schemas/post.ts
export const postSchema = {
name: 'post',
title: 'Post',
type: 'document',
fields: {
title: { type: 'string', title: 'Title', required: true },
category: { type: 'reference', title: 'Category', to: 'category' },
},
}

In the Studio, the editor gets a searchable picker limited to category documents.

Two strings:

{
"title": "Harbour at dawn",
"category": { "_ref": "category-mts0iy4j-e915e10e", "_type": "category" }
}

_ref is the target document’s id. _type is the collection it lives in. That is the whole value — the reference field deliberately does not store a copy of the target’s title or slug, so there is nothing to go stale and nothing to re-sync.

Both parts are required. A value with a _ref but no _type is dropped by the Studio before saving, and the expander ignores it, because it has no collection to look in.

The API also accepts a bare string id for a reference field, kept for compatibility. Do not write new code that way: the string carries no collection, so expansion cannot resolve it and the client falls back to guessing the collection from the id prefix.

to names the target. A string for one collection, an array for several:

author: { type: 'reference', title: 'Author', to: 'author' },
related: { type: 'reference', title: 'Related', to: ['post', 'page'] },

Array entries can also be objects — { type, displayName, icon, filter } — which lets you label a target in the picker and pre-filter the documents it offers.

Omit to entirely and the field accepts any document type. Narrow that with options rather than leaving it open:

featured: {
type: 'reference',
title: 'Featured item',
options: {
includeTypes: ['post', 'event'],
filter: { _status: 'published' },
},
},

filter restricts what the picker offers. It does not restrict what the API accepts, so it is a convenience for editors, not a constraint on your data.

validation.multiple turns the field into an array:

authors: {
type: 'reference',
title: 'Authors',
to: 'author',
validation: { multiple: true, maxReferences: 3 },
options: { sortable: true },
},

The stored value is then an array of { _ref, _type } objects, in the order the editor arranged them. minReferences and maxReferences are enforced by the Studio only.

By default your frontend gets exactly what is stored:

const post = await client.from('post').where('slug', slug).first()
post.category // { _ref: 'category-...', _type: 'category' }
post.category.name // undefined — you have the pointer, not the document

You have two ways to get the document itself.

Fetch it yourself. Right when you need one, or a handful:

const result = await client.getDocument('category', post.category._ref)

Ask the server to expand. Which is the rest of this page.

expand is a query parameter on both document endpoints:

GET /api/collections/post?expand=category
GET /api/collections/post/post-abc123?expand=category

Through the client:

const posts = await client
.from('post')
.published()
.expand('category')
.fetch()
posts[0].category.name // 'Announcements'

Three forms of the parameter:

FormMeaning
expand=categoryOne field. Comma-separate for several: expand=category,author.
expand=authors[]An array-of-references field. The [] is required, or nothing is expanded.
expand=*Every reference field in the schema, found by walking it.

Dot notation reaches into object fields: expand=seo.owner. The wildcard finds those too, along with references inside arrays of objects.

The client mirrors this — .expand('category'), .expand('authors[]') — and each call adds to the list.

The referenced document takes the place of the reference, entirely:

{
"title": "Harbour at dawn",
"category": {
"id": "category-mts0iy4j-e915e10e",
"name": "Announcements",
"slug": "announcements",
"_status": "published",
"_createdAt": "2026-04-02T09:14:00.000Z"
}
}

_ref and _type are gone. Code that reads post.category._ref breaks the moment someone adds expand to the query, and code that reads post.category.name breaks the moment someone removes it. Pick one shape per query and keep the reading code next to the query that produced it.

Two behaviours follow from “entirely”:

A reference that cannot be resolved is left alone. If the target has been deleted, or the fetch throws, the server logs a warning and leaves { _ref, _type } in place. Nothing errors and the response is still a 200. Deleting a document does not clean up references to it, so dangling pointers are a normal state you should expect on read — post.category?.name ?? 'Uncategorised' rather than an assumption.

Expansion ignores publication status. The expanded document is returned whatever its _status. Filtering the parent list to published documents does not filter what gets pulled in through a reference, so a published post can expand a draft author. Check _status on the expanded document if that matters. See Drafts and publishing.

The expander does one fetch per distinct reference per document. That is the part worth understanding before you put expand=* on a list endpoint.

Fetching 50 posts with expand=category:

  • Without expand: 1 read.
  • With expand: 1 read, then 50 more — one per post, even when all 50 point at the same three categories. Deduplication happens within a single document, not across the result set.

The fetches run in parallel, so this is not 50 sequential round trips. It is still 50 reads hitting your storage adapter at once, and on Postgres that is 50 queries.

Two consequences:

  • Expand on detail pages freely. One document, a few references, negligible.
  • Expand on list pages deliberately. If you are rendering a list of 200 posts and only need each category’s name, fetching all categories once and joining in your frontend is one extra read instead of 200.

expand=* is convenient in development and a trap in a list query, because it expands fields you did not think about — including references nested inside object and array fields.

Expansion goes exactly one level deep. If you expand author, and that author has a team reference, the expanded author’s team is still { _ref, _type }.

There is no parameter to increase the depth. To get the second level, expand the first, then query for the second yourself.

After the response arrives, the client checks whether the server actually expanded, by looking for an _id property on the value. If your storage adapter returns its identity field as id rather than _id, that check does not recognise the expansion and the client resolves every reference a second time from the browser or the server rendering the page.

The symptom is a page that works correctly and is slower than the request count suggests. If you are profiling an expanded query, count the requests before concluding the parameter did nothing.

Choosing between expand and a second query

Section titled “Choosing between expand and a second query”

Use expand when the reference is part of the thing you are rendering — a post’s author byline, a page’s hero image credit. One document, one round trip, the data arrives shaped like the page.

Use a second query when the same small set of documents is referenced across a large list. Fetch the categories once, build a map keyed by id, and read post.category._ref out of it:

const [posts, categories] = await Promise.all([
client.from('post').published().fetch(),
client.from('category').fetch(),
])
const byId = new Map(categories.map(c => [c.id ?? c._id, c]))
const categoryFor = (post) => byId.get(post.category?._ref)

Two reads for any number of posts. Less elegant than expand, and it is the difference between one query and two hundred.

  • Field types — the full set of reference options.
  • Querying content — filters, sorting and pagination around all this.
  • Schemas — where reference validation is and is not enforced.