Configuring Locale Fallback Chains in Headless CMS Queries
This guide, part of Content Fallback & Routing, covers the platform side of fallback chains: what each major headless CMS supports natively, how to configure it, and how to query all the locales of a chain efficiently when native support is missing or insufficient. The goal is one request per page, a clear record of the locale actually served, and a strict distinction between missing content and failed requests.
Fallback can be resolved in two places. Some platforms resolve it in the API, returning the fallback value for any empty field when asked for a locale. Others return exactly what exists and leave fallback to the client. Native resolution is convenient but often opaque, because the response does not say which fields fell back. Client resolution is transparent but needs care to avoid one request per chain step. Most production setups combine both: native field fallback where available, and client-side entry-level decisions based on data that shows which locales actually have content.
The Problem
A travel site on Contentful relied on native locale fallback: fr-CA fell back to fr, which fell back to en. Pages always rendered, which looked like success. But nobody could tell which parts of a fr-CA page were actually Canadian French, European French or English, so hreflang listed every page as available in fr-CA, notices were never shown, and the translation team had no data about gaps. A second site on Strapi went the other way: its code walked the chain with one request per locale, and pages for fr-CA made three sequential requests whenever content was missing, adding hundreds of milliseconds exactly where readers were already getting a lesser experience.
How to Configure Chains
Contentful defines one fallback locale per locale in the space settings, forming a chain. Delivery API requests for a locale return fallback values for empty fields automatically. To see which locale served each field, request locale=*, which returns all locales per field, and resolve the chain yourself; this also tells you whether the entry has any real content in the requested locale.
Sanity stores translations according to your schema, commonly as objects keyed by locale for field-level translation or as separate documents for document-level translation. GROQ’s coalesce() resolves field chains in the query, and projecting the raw locale values alongside lets you record the served locale.
Strapi creates separate localized entries linked by a document id. Request the entry with locale for each chain step, or better, fetch the localizations in one request with the localizations relation or by filtering on the document id with several locales.
Storyblok supports field-level translation within a story and folder- or space-based separation for document-level translation. Field-level translatable fields fall back to the default language when empty; for anything more, resolve in code.
Hygraph accepts a list of locales in the locales argument, in priority order, and returns the first available per document; requesting the locale field in the selection shows which one was used.
Implementation
For Contentful, request all locales and resolve in code. The response contains each field as an object keyed by locale.
// lib/cms/contentful-chain.ts
const CHAINS: Record<string, string[]> = { "fr-CA": ["fr-CA", "fr", "en-US"], fr: ["fr", "en-US"], de: ["de", "en-US"], "en-US": ["en-US"] };
type LocalizedFields = Record<string, Record<string, unknown>>; // field -> locale -> value
export async function getEntryWithChain(entryId: string, locale: string) {
const res = await fetch(`${process.env.CMS_REST_URL}/entries/${entryId}?locale=*`, {
headers: { Authorization: `Bearer ${process.env.CMS_DELIVERY_TOKEN}` },
next: { tags: [`entry:${entryId}`, `entry:${entryId}:${locale}`] },
});
if (res.status === 404) return null;
if (!res.ok) throw new Error(`CMS error ${res.status}`); // failure, not a gap: never fall back on this
const { fields } = (await res.json()) as { fields: LocalizedFields };
const chain = CHAINS[locale] ?? [locale, "en-US"];
const out: Record<string, unknown> = {};
const servedBy: Record<string, string> = {};
for (const [name, byLocale] of Object.entries(fields)) {
const hit = chain.find((l) => byLocale[l] !== undefined && byLocale[l] !== "" && byLocale[l] !== null);
if (hit) { out[name] = byLocale[hit]; servedBy[name] = hit; }
}
const ownLocaleFields = Object.values(servedBy).filter((l) => l === locale).length;
return { fields: out, servedBy, hasOwnContent: ownLocaleFields > 0 };
}
hasOwnContent drives the page-level decision: an entry with no fields in the requested locale is a page-level fallback, which gets a notice and the SEO treatment described in canonicalizing fallback pages. Note that locale=* does not resolve linked entries per chain; resolve references with the same function or through includes and the same chain.
For Sanity with field-level translation, coalesce resolves in the query, and projecting each locale’s presence records what served.
*[_type == "page" && slug.current == $slug][0]{
_id,
"title": coalesce(title[$locale], title[$parent], title.en),
"titleServedFrom": select(defined(title[$locale]) => $locale, defined(title[$parent]) => $parent, "en"),
"body": coalesce(body[$locale], body[$parent], body.en)
}
Pass $locale and $parent from the chain configuration. For longer chains, generate the projection from the chain so query and configuration never diverge.
Keeping configuration in one place
Whether the platform resolves chains or your code does, the chain must be identical everywhere: in CMS settings, in query code, in hreflang and sitemap generation, and in the notice logic. Keep the canonical chain definition in your repository, and where the platform has its own settings, such as Contentful’s fallback locales, verify them against the repository in CI through the management API. A mismatch is otherwise invisible until readers see content in an unexpected language.
Configuration Reference
| Platform | Recommended approach | Served locale from |
|---|---|---|
| Contentful | locale=*, resolve in code |
your resolver |
| Sanity | coalesce in GROQ, generated from chain |
projected flags |
| Strapi | fetch localizations in one request | chosen entry’s locale |
| Storyblok | field fallback plus code for documents | your resolver |
| Hygraph | locales: [fr_CA, fr, en] |
locale field in selection |
Gotchas & Edge Cases
- Opaque native fallback. Native fallback that hides which locale served a field makes notices, hreflang and coverage reporting impossible. Use it for convenience only where that information is not needed.
- Payload size. Requesting all locales can multiply payloads on sites with many locales. Select only needed fields, or request only the chain’s locales where the API allows a list.
- References. Linked entries need the same chain resolution, or a page can be French while its related articles are English without anyone noticing.
- Empty versus absent. Some APIs return empty strings, others omit the key. Treat both as missing, and be explicit about empty arrays and empty rich text documents.
Worked Example
The travel site switched from opaque native fallback to locale=* with code resolution, and the Strapi site from sequential requests to one request fetching all localizations. On the travel site, hreflang clusters shrank to locales with real content and fallback notices appeared where appropriate. On the Strapi site, median response time for fallback pages fell from 410 to 150 milliseconds. Both sites now fed the same fallback telemetry, which gave their shared localization team its first comparable coverage numbers.
Testing Chains
Chains are configuration with visible consequences, so test them like code. A table-driven unit test feeds the resolver fixtures with every combination that matters: all locales present, only the parent present, only the default present, nothing present, empty strings, empty arrays and rich text documents with no content. Assert both the resolved value and the recorded served locale. An integration test against a staging space with a handful of entries in known translation states checks that the platform behaves as expected, including references. Finally, the CI check that compares the repository chain with the platform’s settings runs on every deploy. Together these catch the common failures: a chain that silently skips the parent language, a resolver that treats an empty rich text document as content, and platform settings changed by hand.
Rollout Checklist
- Define chains once in the repository and verify platform settings against them.
- Query all chain locales in one request per page.
- Resolve in code where you need the served locale; use native fallback only where you do not.
- Resolve references with the same chain.
- Treat transport errors as failures, never as missing content.
- Test resolver fixtures for every missing-value shape.
Frequently Asked Questions
Is native fallback ever enough?
For sites that do not show notices, do not need precise hreflang and do not report coverage, yes. Most multilingual sites eventually need at least one of those.
How long can a chain be?
Technically unlimited; practically two or three steps. Longer chains make results hard to predict for editors and readers.
Should chains differ by content type?
The chain itself should be the same; whether a type falls back at all can differ. Legal content, for example, should not fall back.
What about locales added later?
Add the locale and its chain to the repository configuration, then to the platform settings, and let CI confirm they match before the locale goes live.