REST Includes and Sparse Fieldsets for CMS APIs

Within GraphQL vs REST API Tradeoffs, this guide covers the two REST features that close most of the gap with GraphQL: includes, which resolve referenced entries in the same response, and sparse fieldsets, which return only the fields you ask for. Together they reduce round trips and payload size while keeping REST’s simple, cacheable URLs.

Almost every headless CMS supports both, under different names. Contentful has include depth and select; Strapi has populate and fields; Directus has fields with dot notation for relations; Storyblok has resolve_relations; JSON:API-based platforms use include and fields[type]. The ideas are the same, and so are the pitfalls, mostly around resolving too much.

Includes and field selection by platformThe parameters each CMS uses to resolve references in the same response and to limit returned fields.CMSResolve referencesSelect fieldsContentfulinclude=0..10 (depth)select=fields.title,…Strapipopulate[author]=truefields[0]=titleDirectusfields=author.namefields=title,slugStoryblokresolve_relations=article.authorfilter in codeJSON:APIinclude=authorfields[article]=title
Names differ, but every major platform supports both ideas in its delivery API.

The Problem

A documentation site built on a CMS’s REST API made four requests per page: the page, its author, its related pages and its section navigation. Each response returned every field of every entry, including long rich text bodies of related pages that were only shown as titles. A typical page loaded 260 KB of JSON from four requests in sequence, because each request depended on ids from the previous one. Server rendering took over a second, and the team was considering GraphQL mainly to fix this.

How Includes and Field Selection Work

Includes tell the API to resolve references and return the referenced entries in the same response. Some platforms embed them in place, like Strapi’s populate; others return them in a separate includes section that the client links by id, like Contentful. Either way, one request replaces a chain of dependent requests. Include depth controls how many levels are resolved: depth 1 returns the author of the page, depth 2 also the author’s own references.

Sparse fieldsets tell the API which fields to return. Selecting title,slug for related pages instead of the whole entry often cuts payloads by an order of magnitude, since rich text fields dominate most responses.

Both keep the request a plain GET with parameters in the URL, so responses remain cacheable at the CDN, keyed by the full URL.

Four sequential requests become oneWithout includes, the page request is followed by requests for the author, the related pages and the navigation, each waiting for ids from the previous response; with includes and field selection, one request returns the page with its references and only the needed fields.Server renderCMS REST APIGET /pages/intropage (ids only)GET /authors/a7, /pages?ids=…, /nav/docs3 more responsesafter: one GET withinclude + selectGET /pages/intro?include=2&select=…page + references, trimmed
Removing the dependency chain matters more than the byte savings for server render time.

Implementation

The example uses Contentful’s REST delivery API, where includes arrive in a separate section. A small helper resolves links so the rest of the code sees nested objects, as it would with GraphQL.

TypeScript
// lib/cms/rest.ts
interface Link { sys: { type: "Link"; linkType: "Entry" | "Asset"; id: string } }
interface Entry { sys: { id: string; contentType?: { sys: { id: string } } }; fields: Record<string, unknown> }
interface Collection { items: Entry[]; includes?: { Entry?: Entry[]; Asset?: Entry[] } }

function resolveLinks(value: unknown, index: Map<string, Entry>, depth: number): unknown {
  if (depth < 0 || value === null || typeof value !== "object") return value;
  if (Array.isArray(value)) return value.map((v) => resolveLinks(v, index, depth));
  const maybeLink = value as Link;
  if (maybeLink.sys?.type === "Link") {
    const target = index.get(maybeLink.sys.id);
    return target ? { id: target.sys.id, ...(resolveLinks(target.fields, index, depth - 1) as object) } : null; // null: unpublished or beyond include depth
  }
  return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, resolveLinks(v, index, depth)]));
}

export async function getDocPage(slug: string) {
  const url = new URL(`${process.env.CMS_REST_URL}/entries`);
  url.searchParams.set("content_type", "docPage");
  url.searchParams.set("fields.slug", slug);
  url.searchParams.set("include", "2"); // page -> related pages -> their authors
  url.searchParams.set("select", "sys.id,fields.title,fields.body,fields.author,fields.related");
  url.searchParams.set("limit", "1");

  const res = await fetch(url, {
    headers: { Authorization: `Bearer ${process.env.CMS_DELIVERY_TOKEN}` },
    next: { tags: [`doc:${slug}`] },
  });
  const data = (await res.json()) as Collection;
  if (!data.items[0]) return null;

  const index = new Map<string, Entry>();
  for (const e of [...(data.includes?.Entry ?? []), ...(data.includes?.Asset ?? [])]) index.set(e.sys.id, e);
  return { id: data.items[0].sys.id, ...(resolveLinks(data.items[0].fields, index, 2) as object) };
}

One limitation is visible here: in Contentful, select applies to the top-level entries but not to included entries, which arrive complete. Strapi and Directus allow field selection on relations too, for example populate[related][fields][0]=title. Where included entries cannot be trimmed, keep include depth low and fetch large related content separately only when needed.

Choosing include depth

Every extra level of include depth multiplies the potential size of the response. Depth 1 or 2 covers most pages: the entry, its direct references and occasionally their references. Deeper includes tend to pull in navigation trees, related-content chains and circular references that the page never renders. Set depth per query based on what the page actually shows, and make the resolver stop at the same depth, so a missing entry beyond the depth is treated as missing rather than causing an error.

Keeping query shapes in one place

Include depth and field lists are easy to scatter across a codebase, with every component adding the fields it needs to whichever query happens to feed it. Over time the lists grow, nobody knows which fields are still used, and payloads creep back toward their original size. Define each page type’s query shape once, in the data layer, next to the runtime schema that validates the response, and derive the field list from that schema where the tooling allows it. A field removed from the schema then disappears from the request automatically, and a code review of one file shows exactly what each page fetches.

Configuration Reference

Parameter Recommendation Why
Include depth 1 or 2, per query Deeper levels grow responses quickly.
Field selection only rendered fields, always Rich text fields dominate payload size.
Page size for lists as small as the view needs Includes are multiplied by list length.
Cache key full URL Different fields or depths are different responses.
Missing references resolve to null Unpublished entries are omitted from includes.
Cache tags entry id plus included ids Precise invalidation when referenced content changes.

Gotchas & Edge Cases

  • Include limits. Platforms cap the number of included entries per response. Large lists with deep includes can silently drop some references. Check the limit and reduce page size or depth.
  • Circular references. Pages that reference each other are returned once in the includes section, but a naive resolver can loop forever. Always pass a depth limit to the resolver, as above.
  • Selecting reference fields. Selecting a reference field returns the link, not the target’s fields. The target comes from includes, so both the reference field and enough include depth are needed.
  • Cache fragmentation. Every distinct combination of fields and depth is a separate cache entry. Keep the set of query shapes small and shared across pages of the same type.

Worked Example

The documentation team replaced its four sequential requests with one request using include depth 2 and field selection for the page, and a separately cached navigation request shared by all pages. Payload per page fell from 260 KB to 48 KB, server render time from 1.1 seconds to 280 milliseconds, and the CDN hit rate for page data rose because the new URLs were stable per page. The team kept REST: the remaining gap to GraphQL did not justify a new query layer.

Page data payload before and afterJSON transferred to render one documentation page with four sequential full requests, with one request using includes, and with includes plus field selection.4 requests, all fields260 KB1 request, includes190 KBIncludes + field selection48 KB
Field selection accounted for most of the byte savings; includes removed the request chain.

When REST Features Are Not Enough

Includes and sparse fieldsets handle most pages, but some views still fit GraphQL better. Pages that combine polymorphic blocks, each with its own references and fields, need different field selections per block type, which REST parameters express poorly; they either over-fetch for every block or need one request per block type. Views that combine several sources, such as content and live pricing, need composition that the CMS’s REST API cannot do. And mobile clients that need very different shapes per screen benefit from GraphQL’s per-query selection. When those cases appear, add GraphQL for them, or put a BFF in front that uses REST internally and returns page-shaped responses, rather than switching the whole project.

Rollout Checklist

  • Map each page type’s rendered fields and references.
  • Replace dependent request chains with one request using includes.
  • Select only rendered fields, at every level the platform allows.
  • Resolve links with a depth-limited helper that returns null for missing entries.
  • Tag cached responses with the ids of included entries.
  • Keep a small set of shared query shapes to avoid cache fragmentation.

Frequently Asked Questions

Are includes as efficient as a GraphQL query?

For fixed page shapes, nearly. GraphQL still wins when different blocks need different fields, because REST field selection is usually per content type rather than per usage.

Do includes count against rate limits differently?

Usually one request is one request, regardless of includes. Some platforms weigh responses by size or complexity, so check the documentation.

Should navigation be included in page requests?

No. Navigation is shared by every page; fetch it once with its own long-lived cache entry instead of including it in every page response.

What if the platform does not support field selection?

Trim responses in a BFF or at the fetch boundary before caching. The network cost remains, but cached and serialized payloads shrink.