Choosing a Content API Shape for SEO Builds
The shape of your content queries decides whether a page can be rendered completely on the server in one round trip — and that single property determines most of what follows in a decoupled build.
When to Use This Approach
Evaluate the API shape when:
- Build times are growing faster than content volume, which points at a request waterfall.
- Templates fetch content client-side because “the API call is too slow for the server render”.
- A new content type is being added and its query pattern is about to be established by default.
The Waterfall Is the Problem
A template that fetches an entry, then its author, then its category, then each image serialises four round trips per page. At thirty thousand pages that is the whole build.
Implementation Steps
Step 1: Enumerate what one render actually needs
# templates/article.requirements.yml
fields:
- title
- standfirst
- body # rich text with embedded references
- publishedAt
- updatedAt
references:
author: [name, slug, path, avatar]
category: [title, slug, path]
hero: [url, alt, width, height]
related: [title, path] # 3 items, shallow
metadata:
- seo.title
- seo.description
- seo.noindex
- path
- previousPaths
Validation: Render the template with only these fields available. Anything that breaks is a requirement you missed; anything unused is payload you are paying for on every page.
Step 2: Measure the current round trips
// Instrument the CMS client during a single page render
let calls = 0;
const timings = [];
export async function instrumentedFetch(query, vars) {
const t0 = performance.now();
const res = await cmsFetch(query, vars);
timings.push({ n: ++calls, ms: performance.now() - t0, query: query.slice(0, 40) });
return res;
}
// After rendering one page
console.table(timings);
console.log(`total ${calls} calls, ${timings.reduce((a, t) => a + t.ms, 0).toFixed(0)} ms`);
Validation: Multiply the total by your route count. If the result exceeds your acceptable build time, the API shape is the constraint — not the renderer, not the hosting.
Step 3: Collapse to one query per page
query ArticlePage($path: String!) {
article(where: { path: $path }) {
title
standfirst
body { json links { assets { url alt width height } entries { title path } } }
publishedAt
updatedAt
path
seo { title description noindex }
author { name slug path avatar { url width height } }
category { title slug path }
hero { url alt width height }
related(limit: 3) { title path }
}
}
SEO impact: The whole page — content, metadata, structured-data inputs, and internal links — becomes available before rendering starts, which is the precondition for complete server HTML.
Validation: Re-run Step 2. The call count should be exactly one.
Step 4: Batch the build
Per-page queries at build time still pay one round trip per page. Fetching in batches converts that to one per hundred.
// scripts/fetch-all.mjs
const PAGE_SIZE = 100;
export async function* allArticles() {
let cursor = null;
do {
const { items, nextCursor } = await cms.query(ARTICLE_PAGE_BATCH, {
first: PAGE_SIZE,
after: cursor,
});
yield* items;
cursor = nextCursor;
} while (cursor);
}
SEO impact: Build duration becomes a function of content volume and payload size rather than of network latency, which is what makes a large catalogue buildable at all.
Validation: Time a build at 1,000 and 10,000 entries. The relationship should be close to linear with a small constant, not dominated by a per-page constant.
Step 5: Cache by content hash
// lib/build-cache.js
import { createHash } from 'node:crypto';
export function cacheKey(entry) {
const payload = JSON.stringify({
id: entry.id,
updatedAt: entry.updatedAt,
refs: collectReferences(entry).map((r) => [r.id, r.updatedAt]),
});
return createHash('sha1').update(payload).digest('hex').slice(0, 16);
}
export async function renderCached(entry, render) {
const key = cacheKey(entry);
const hit = await cache.get(key);
if (hit) return hit;
const html = await render(entry);
await cache.set(key, html);
return html;
}
SEO impact: Including reference timestamps in the key means a shared asset change correctly invalidates every page that renders it — the same rollup logic that makes lastmod accurate.
Validation: Run two consecutive builds with no content change. The second should be almost entirely cache hits.
Choosing Between API Styles
The question is not which technology but which of three properties your build needs most.
SEO Impact Summary
Build duration is the ceiling on content freshness, so the API shape ultimately decides how current your indexed content can be.
| Signal | What improves | What breaks if misconfigured |
|---|---|---|
| Server rendering | Complete HTML is producible because all data arrives before render | A slow API pushes teams toward client fetching, emptying the server response |
| Content freshness | Short builds mean updates reach the index within hours | Waterfall builds bound freshness to the deploy cadence |
| Crawl efficiency | Fast origin responses sustain a higher crawl rate | Per-request API latency shows up directly in time to first byte |
| Link discovery | Referenced entries arrive with the entry, so links render server-side | Missing reference data turns internal links into client-rendered placeholders |
Measurable signals to watch:
- API calls per page render, asserted at one in a test.
- Build wall-clock time per thousand entries, trended across releases.
- Cache-hit ratio on consecutive builds with no content change.
Edge Cases and Gotchas
Query complexity limits will stop a naive composed query Most hosted CMS APIs cap query depth or complexity. Split by concern rather than by reference — one query for the page, one for a large related collection — instead of returning to a per-reference waterfall.
Rich text with embedded entries needs its links resolved in the same call If embedded references come back as identifiers you then resolve individually, you have reintroduced the waterfall inside the body field. Request the resolved links alongside the rich-text document.
Preview queries are a different shape Draft content usually bypasses the CDN and the published index, so preview queries are slower and less cacheable. Keep them on a separate code path so preview latency never influences the production query shape — the isolation reasoning is in preview & draft content SEO.
Field selection is also a payload-size decision at the edge Requesting the whole record because it is convenient inflates every cached response. On a large catalogue the difference between a selected and an unselected query can be gigabytes of cache occupancy.
Frequently Asked Questions
Is GraphQL better than REST for a headless SEO build? For rendering complete pages, usually — one query returns an entity and its references at the depth the template needs, removing the waterfall. The advantage is the single round trip rather than the language; a REST API with an include or depth parameter achieves the same.
How deep should a content query go? Exactly as deep as the template renders. Two or three levels covers most pages: entry, referenced entries, their assets. Deeper inflates payloads and build times; shallower produces a second round trip that costs more than the fields would have.
Does the API shape actually affect rankings? Not directly, but it sets a ceiling on two things that do. A slow API pushes content out of the server HTML, and long builds bound content freshness to your deploy cadence. Both show up in indexing outcomes.
What about pages that genuinely need live data? Split the page: render the stable content statically and fetch the volatile fields separately, either streamed or client-side. Price and stock are the canonical example — neither is required for the page to be indexable, and neither is ever the largest contentful element.
Part of: Composable CMS Architecture Basics
Related
- Mapping Content Models to Crawlable URL Structures — the addressability decisions these queries then serve
- Configuring Next.js ISR for Optimal Crawl Budget — regenerating pages incrementally when a full build is too slow
- Edge Caching Behavior for SEO — where the API latency you cannot remove gets absorbed instead