References vs Embedded Objects in Headless Content Models

Within Content Modeling Best Practices, this guide covers the most frequent modeling decision of all: whether a piece of related content should live in its own entry and be referenced, or live inside its parent as an embedded object. The choice affects how editors work, how many requests a page needs, how precisely caches can be invalidated and how localization behaves.

Every headless CMS offers both options under different names. Contentful has reference fields and JSON or rich text objects; Sanity has references and inline objects; Strapi has relations and components; Storyblok has story links and nested bloks; Hygraph and Directus have relations and embedded components or JSON. The mechanics differ; the decision is the same everywhere.

The same author, modeled two waysWith references, forty articles point at one author entry, so a bio change is made once; with embedding, each article holds its own copy of the author, so the bio exists forty times and drifts.Article 1Article 2Article 40Author entryone bioArticle 1+ author copyArticle 2+ author copyArticle 40+ author copyrefrefref
Shared content wants references; content that belongs to one parent wants embedding.

The Problem

A publisher embedded author details in every article: name, role, photo and a short bio, entered as an object in each article. When an author changed roles, editors had to find and update more than a hundred articles, and about a third were missed. Search engines showed different job titles for the same person in structured data. At the same time, the publisher modeled the items of every “key facts” box as separate entries referenced from articles, so each article with a facts box had six to ten tiny entries that editors had to create, name and publish one by one, and unpublished facts regularly left gaps on live pages.

Both problems came from the same missing rule: the model did not distinguish between shared content and content that belongs to exactly one parent.

How to Decide

Two questions decide nearly every case.

Is it shared? If the same content appears under several parents and must stay identical, it should be a reference. Authors, categories, products, locations, legal disclaimers and reusable promotional banners are shared. The items of one article’s facts box are not.

Does it have its own lifecycle? If the content is edited by different people, published on a different schedule, localized differently or needs its own permissions, it should be a reference even when only one parent uses it today. A product’s regulatory notice that legal must approve separately is an example.

If both answers are no, embed. Embedded objects are edited in place, published with their parent, versioned with their parent and fetched without an extra query. That is exactly right for list items, table rows, links, button labels, SEO fields and layout options.

Consequences of each choiceHow references and embedded objects compare for editing, querying, caching, localization and history.ConcernReferenceEmbedded objectEditingseparate entry to manageedited in placeConsistency across pagesone source of truthcopies driftQuery costresolution or include depthreturned with parentCache invalidationtag by referenced idonly via the parentPublishingcan be unpublished separatelypublished with parentLocalizationown locale settingsfollows parent
References trade query and editing overhead for consistency and precise invalidation.

Implementation

In code, the difference shows up in three places: the query, the cache tags and the handling of missing data. A reference must be resolved, may be missing or unpublished, and should add its id to the page’s cache tags. An embedded object comes with the parent and needs none of that.

TypeScript
// lib/cms/article.ts (Sanity GROQ, but the pattern is the same elsewhere)
import { client } from "./client";

const query = `*[_type == "article" && slug.current == $slug][0]{
  _id,
  title,
  "author": author->{ _id, name, role, "photo": photo.asset->url },  // reference: resolved with ->
  keyFacts[]{ _key, label, value },                                   // embedded: returned inline
  seo { title, description }                                          // embedded object
}`;

interface Article {
  _id: string;
  title: string;
  author: { _id: string; name: string; role: string; photo: string } | null;
  keyFacts: { _key: string; label: string; value: string }[] | null;
  seo: { title?: string; description?: string } | null;
}

export async function getArticle(slug: string) {
  const article = await client.fetch<Article | null>(query, { slug });
  if (!article) return null;
  const tags = [`entry:${article._id}`];
  if (article.author) tags.push(`entry:${article.author._id}`); // referenced ids join the page's tags
  return { article, tags };
}

The page component renders a neutral byline when author is null, which happens when the author entry is unpublished or deleted, and treats keyFacts as an empty list when absent. The cache tags mean that editing the author revalidates every article that references them and nothing else. With embedded authors, the only way to update all pages would have been a full rebuild.

Assets are references with embedded usage data

Images are a special case worth modeling deliberately. The asset itself, the file with its dimensions and default metadata, is shared and should be a reference. The way a particular page uses it, the crop, focal point, caption and sometimes page-specific alt text, belongs to that usage and should be embedded next to the reference. Most platforms support this shape directly: Sanity’s image type holds an asset reference plus hotspot and crop, and in Contentful a small “media usage” object type around an asset link does the same.

Reusable blocks: the case in between

Page builders often need a third option: a block that editors can either configure inline on one page or pick from a library of shared instances, such as a newsletter signup or a promotional banner used on dozens of pages. Model the library instance as its own content type, and add a small “shared block” type to the page’s block union that only holds a reference to it. Editors then choose between an inline block, which they can change freely, and a shared one, which changes everywhere when its entry is edited. The frontend resolves the reference and renders the target with the same component as the inline version, so the choice is purely editorial. Make the difference visible in the CMS, for example with a distinct icon and label, because editors who change a shared block believing it is local are the most common source of surprise edits across a site.

Configuration Reference

Content Usual choice Reason
Author, category, tag reference Shared across many entries.
Product in editorial content reference Shared, owned by commerce data.
Legal notice, disclaimer reference Own approval lifecycle.
List items, table rows, FAQs of one page embedded Belong to one parent.
Links and buttons embedded, with an internal reference inside The link is local, its target is shared.
SEO fields embedded One per page, published with it.
Images asset reference plus embedded usage data File shared, crop and caption local.

Gotchas & Edge Cases

  • Reference depth. Resolving references inside references quickly multiplies query cost. Resolve one or two levels and fetch deeper data separately where it is actually needed.
  • Unpublished references. A published page can reference a draft entry, which the delivery API omits. Always handle a missing reference in the component, as covered in handling references to unpublished entries.
  • Converting later. Turning embedded copies into references requires a migration that deduplicates the copies, which is harder than it sounds when they have drifted. Decide early for anything that might be shared.
  • Circular references. Articles referencing related articles that reference back are fine in the model, but queries must cap depth explicitly.

Worked Example

The publisher converted authors into a referenced type with a migration that grouped embedded author objects by normalized name, created one entry per author with the most recent bio, and replaced each embedded object with a reference. Editors reviewed the eleven cases where copies had diverged. Key facts went the other way: the separate fact entries became an embedded list on the article, which removed about four thousand tiny entries from the CMS. Afterwards, a role change took one edit and revalidated the author’s articles within seconds, and creating an article with a facts box took one publish instead of eight.

Entries editors had to manageThe number of entries in the space before and after converting authors to references and key facts to embedded lists.Author copies before1240 entriesAuthor entries after86 entriesFact entries before4120 entriesFact entries after0 entries
Fewer entries overall, and the ones that remain are the ones worth sharing.

Rollout Checklist

  • List every relationship in the model and answer the sharing and lifecycle questions for each.
  • Convert shared embedded content to references with a deduplicating migration.
  • Convert single-parent referenced content to embedded objects to reduce editing overhead.
  • Add referenced ids to the page’s cache tags.
  • Handle missing references in every component that renders one.

Frequently Asked Questions

Are references slower than embedded objects?

A little, because they must be resolved, either by the CMS in the same query or by an extra request. With include depth in REST or joins in GraphQL and GROQ, the difference is small. The consistency and invalidation benefits usually outweigh it.

Can an embedded object become a reference later?

Yes, with a migration, but it is more work than the reverse, because copies must be deduplicated and reconciled. Choose references early for anything that might be shared.

Should navigation items reference pages?

Yes. A navigation item is embedded in the navigation entry, and it references the page it points to, so the URL updates when the page’s slug changes. The label can stay embedded if it differs from the page title.

How deep should references be resolved?

One level in most queries, two where the design needs it. Deeper data should be fetched separately for the part of the page that shows it.