Work with multilingual content
Goal: fetch a content entry in a specific language and know, from the response alone, whether you got the translation you asked for.
What you’ll have when you’re done
Section titled “What you’ll have when you’re done”- A request that returns an entry in a chosen language, and fails loudly (
404) when that translation doesn’t exist. - A deliberate choice about fallback: strict by default, or opt in with one parameter and read the served language off the response.
- One mapping function your frontend uses to turn a route locale into an API language: the same pattern every page of your frontend reuses.
Prerequisites
Section titled “Prerequisites”- A working token: the auth quickstart sets this up, including the
.envfile used below. - A site with at least two languages and at least one translated entry. To add a language:
Configuration > Region and language > Languages > Add language. To translate an entry: open it in the CMS, use itsTranslatetab, and add the second-language version; adding a language alone translates nothing. - Nothing to enable: every Source CMS site ships the JSON:API Multilingual module active, with no setup needed. On a non-Source stand-in, install and enable the module first; step 2’s strict read doubles as the check.
- You’ve fetched an entry over JSON:API before; if not, start with the query quickstart.
-
Find your site’s language codes
Section titled “Find your site’s language codes”In your site’s admin UI,
Configuration > Region and language > Languageslists every enabled language (administrator-level; an editor account sees the available languages on theTranslationsscreen instead, observed 2026-08-19). Each has a langcode (en,es,hi) and those exact codes are what the API speaks: they appear as thelangcodeattribute in every response, and they are the values thelangCodequery parameter accepts.Mind the casing, because two different names are one letter apart:
langcode(all lowercase) is the response attribute, andlangCode(camelCase) is the query parameter. The API rejects a lowercase?langcode=query parameter with400(it violates JSON:API’s parameter naming rules, which is also why the real parameter has a capital letter).Translation is per content type: the type must have
Enable translationswitched on underStructure > Content types >your type> Language settings, and the translatable fields are checked underConfiguration > Region and language > Content language and translation. -
Request the entry in a specific language
Section titled “Request the entry in a specific language”Add
?langCode=to the entry’s URL. The parameter is strict: you get exactly the translation you asked for, or a404naming the translations that do exist. Per the JSON:API Multilingual documentation, this works on every operation and never depends on the URL’s language prefix. Verified against a live Source CMS site (2026-08-19):GET /api/node/article/{id}?langCode=fireturned the Finnish translation withContent-Language: fionce it existed, and the strict404before it did.Pass the parameter as a query string (the Drupal API Client configured as in the query quickstart;
idis the entry’s UUID):const entry = await client.getResource("node--article",id,{ queryString: "langCode=es" },);console.log(entry.data.attributes.langcode); // expect: es// DRUPAL_SITE_URL, access_token, and id as in// the query quickstartconst response = await fetch(`${DRUPAL_SITE_URL}/api/node/article/${id}?langCode=es`,{headers: {Accept: "application/vnd.api+json",Authorization: `Bearer ${access_token}`,},});console.log(response.status, response.headers.get("content-language"));Terminal window set -a; source .env; set +a# TOKEN as in the query quickstart; ENTRY_ID is any entry's id from a collection response (the writing-content guide captures one)curl -si "$DRUPAL_SITE_URL/api/node/article/$ENTRY_ID?langCode=es" \-H "Accept: application/vnd.api+json" \-H "Authorization: Bearer $TOKEN"A successful response returns the translation. The
Content-Languageresponse header and thelangcodeattribute both name the language served, and under a barelangCodethat is always the language you asked for:{"data": {"type": "node--article","id": "0dfd4171-2b13-4c04-9c3e-0d6f2a7f3a11","attributes": {"langcode": "es","title": "Anuncio de lanzamiento","default_langcode": false}}}When the translation does not exist, the response is a
404whose error object advertises the translations you may view: human-readably inerrors[0].detailand machine-readably as a sorted array inerrors[0].meta.availableTranslations. Captured live (2026-08-19):The "fi" translation of the specified resource does not exist. Available translations: en.with"meta": { "availableTranslations": ["en"] }. That array is the entry’s language menu: re-request with one of its values, or opt into fallback (step 3).Requesting a language that isn’t enabled on the site at all is rejected rather than missed, so a typo in the code fails differently from a missing translation:
422withThe specified language ("de") is invalid or has not been configured.(captured 2026-08-19). -
Opt into fallback deliberately
Section titled “Opt into fallback deliberately”When you would rather have some translation than a
404, addincludeFallback=1. The server resolves the best available translation through the site’s language fallback chain (the same mechanism the rendered site uses; the default translation is the terminal candidate), so an existing entry always returns200. Verified live (2026-08-19): the same request that 404ed strictly returned200with the default translation andContent-Language: enunder fallback:Terminal window curl -si "$DRUPAL_SITE_URL/api/node/article/$ENTRY_ID?langCode=es&includeFallback=1" \-H "Accept: application/vnd.api+json" \-H "Authorization: Bearer $TOKEN"The response tells you what you actually got: the
Content-Languageheader and thelangcodeattribute carry the served language, which under fallback may differ from the one you requested. Detecting fallback is comparing the two:const requested = 'es';const { data: entry } = await response.json();const isFallback = entry.attributes.langcode !== requested;includeFallbackis valid only together withlangCode, only on reads, and only with the literal value1. The rejections are400s with self-explanatory details (captured 2026-08-19):The "includeFallback" query parameter requires the "langCode" query parameter.andThe "includeFallback" query parameter only accepts the value "1".Keep the failure shapes apart:
- Missing translation, strict read:
404carryingmeta.availableTranslations. The entry exists; that translation doesn’t. - Fallback content:
200, butlangcodeisn’t what you requested. Render it, hide it, or show a “not available in your language” notice; that’s a product decision your code can now make deliberately. - Missing content:
404with noavailableTranslations, or"data": []for a collection: the entry itself doesn’t exist (or isn’t published). No language will help.
- Missing translation, strict read:
-
List entries in a language, not just one entry
Section titled “List entries in a language, not just one entry”The same two parameters work on collection URLs, and the strict/fallback choice matters more there because it decides what the list contains:
- Strict (
?langCode=es): only entries that have a Spanish translation, each returned in Spanish. Entries without one are excluded, never served in another language. The collection itself does not itemize what it excluded; when you need the exclusions reported, that exists onrelatedreads and onincludes (below), not on plain collections. - Fallback (
?langCode=es&includeFallback=1): every entry, each resolved through the fallback chain; entries with a Spanish translation come back in Spanish and the rest in their chain-resolved language. Apply the step 3 check per entry to tell them apart, becauseContent-Languagereports per-entry only on single-entry responses.
On a site with one Finnish translation among three entries,
?langCode=fireturns exactly the translated entry in Finnish (meta.count: 1), and?langCode=fi&includeFallback=1returns all three, onefiand twoen.const entries = await client.getCollection("node--article",{ queryString: "langCode=es&includeFallback=1" },);const exact = entries.data.filter((entry) => entry.attributes.langcode === "es",);const response = await fetch(`${DRUPAL_SITE_URL}/api/node/article?langCode=es&includeFallback=1`,{headers: {Accept: "application/vnd.api+json",Authorization: `Bearer ${access_token}`,},});const json = await response.json();const exact = json.data.filter((entry) => entry.attributes.langcode === "es",);Terminal window set -a; source .env; set +acurl -s "$DRUPAL_SITE_URL/api/node/article?langCode=es&includeFallback=1" \-H "Accept: application/vnd.api+json" \-H "Authorization: Bearer $TOKEN"A language with no matching entries returns
"data": [], not an error: the missing-content shape from step 3.An
includehonors the same selection: under strictlangCode, an included resource with no translation in that language is left out ofincludedand reported undermeta.omitted, its per-item detail readingNo translation of the included resource matches the request's language selector. Available translations: en.; under fallback it is chain-resolved like everything else. Themeta.omittedwrapper’s own top-leveldetailis core’s generic omission text about authorization, so read the per-item details, not the wrapper, to tell language omissions apart.filter[langcode]still works, but it only filters, it does not select the served language:filter[langcode]=fimatches entries that have a Finnish translation and still returns them in the default language. “List entries that exist in this language, in this language” is?langCode=alone; reach for the filter only when you want existence-of-translation as a condition combined with some other selection. - Strict (
-
Stop relying on the URL path prefix
Section titled “Stop relying on the URL path prefix”Language selection used to ride the URL as a langcode path prefix in front of
/api, and integrations built on that pattern still exist: the Drupal API Client’slocaleoption (client.getResource(type, id, { locale: "es" })) and itsdefaultLocaleconstructor option both build prefixed URLs. Whether the prefix actually selects the content language is the site’s negotiation configuration, not a contract, and the two verification runs landed on opposite sides of it. A local product instance negotiated by prefix (2026-07-03). On a live Source CMS site (2026-08-19),GET /fi/api/node/article/{id}resolved but returned the default-language entry (langcode: "en",default_langcode: true) even though the Finnish translation existed. A prefix for a language the site doesn’t enable is a404either way.So treat the prefix as legacy routing, not language selection: an integration that gets its language from
locale,defaultLocale, or hand-built prefixes can silently serve the default language on a current Source CMS site. Move the selection into the query string (queryString: "langCode=es", step 2), which is explicit, works the same on every URL (pagination links included), and fails loudly when the translation is missing unless you opted into fallback.Accept-Languagenever negotiates content language regardless of mechanism (the module’s reads ignore language request headers by contract). -
Map route locales to API languages, once
Section titled “Map route locales to API languages, once”Your frontend’s route locales (
/es/blog/...) and the site’s langcodes are two namespaces that happen to overlap. Map them explicitly in one module instead of interpolating route params into API URLs all over the codebase:lib/content-language.js const LOCALE_TO_LANGCODE = {en: 'en',es: 'es',};export function contentUrl(locale, path) {const langcode = LOCALE_TO_LANGCODE[locale];if (!langcode) {throw new Error(`No API language mapped for locale "${locale}"`,);}const url = new URL(path,process.env.DRUPAL_SITE_URL,);url.searchParams.set('langCode', langcode);url.searchParams.set('includeFallback', '1');return url.toString();}// contentUrl('es', '/api/node/article')// → https://your-site.example.com/api/node/article?langCode=es&includeFallback=1The locales your frontend routing declares must match languages that actually exist on the site: an unmapped or mismatched locale fails loudly here, at the one place responsible, instead of silently serving the wrong language. When you add a language in the admin UI, you add one line to this map.
Whether the function sets
includeFallbackis your product’s fallback policy, decided once. With it, pages always render and step 3’s check flags the fallbacks. Without it, a missing translation surfaces as a404for your route handler to turn into your own not-available treatment.
What just happened
Section titled “What just happened”Every entry carries its language as the langcode attribute, and translations of an entry share one id: the langCode parameter selects which translation you get, strictly by default, through the site’s fallback chain when you add includeFallback=1. The strictness is the point: fallback stops being something that happens to you and becomes something you requested, and either way the response names the language served (Content-Language header, langcode attribute). Only fields marked translatable differ between translations; untranslatable fields return the same value in every language, by design; that’s shared data, not a fallback signal, so don’t use field equality to detect anything. Which fields are translatable on each type is part of your content model: see the content model reference. To create, update, or delete one translation over the API, see translating content.
When something goes wrong
Section titled “When something goes wrong”404 with errors[0].meta.availableTranslations on ?langCode=...
the entry exists, that translation doesn’t, and the array names the languages that do (captured 2026-08-19). Re-request one of those, add includeFallback=1, or create the translation in the CMS.
400 with The following query parameters violate the JSON:API spec: 'langcode'.
the parameter is camelCase langCode; lowercase langcode is not a valid JSON:API parameter name and is rejected. The same casing rule applies to includeFallback.
422 with The specified language ("...") is invalid or has not been configured.
the langCode isn’t an enabled language on the site (codes are exact: es, not ES; captured 2026-08-19). Check Configuration > Region and language > Languages, or on Source CMS the Translations screen’s language list.
?langCode= is silently ignored: 200, default language, no strict 404s
the site you’re calling doesn’t have the JSON:API Multilingual module active. Core JSON:API accepts spec-compliant custom parameters without acting on them, so the requests succeed while the selection does nothing. Every Source CMS site ships the module (confirmed 2026-08-19), so against Source this means the URL points at a different site than you think; a local stand-in without the module behaves exactly this way.
A client error on includeFallback
it needs langCode beside it, the literal value 1, and a read: includeFallback on a write is rejected with 400 The "includeFallback" query parameter is only supported on read requests. (captured 2026-08-19).
Response langcode differs from the one you requested, status 200
with includeFallback=1, that’s fallback working as designed: the translation doesn’t exist and the chain resolved to another language. Create the translation, or handle fallback deliberately (step 3). On a path-prefix URL it means the prefix isn’t selecting the content language at all (step 5): move the selection to langCode.
"data": [] for a strict langCode collection
distinguish two causes before touching language settings. Re-run with includeFallback=1 (or with no language selector): if entries appear, the translations are missing, not the content; if it stays empty, the entries themselves don’t exist or aren’t published.
Translated entry returns, but some fields are still in the default language
those fields aren’t marked translatable. Check them under Configuration > Region and language > Content language and translation; only checked fields carry per-language values.
404 Not Found on /es/api/... while /api/... works
the langcode in the prefix isn’t an enabled language on the site, or is misspelled. And an enabled language’s prefix resolving is not the same as it selecting: see step 5.
Accept-Language has no effect
correct, on reads it never negotiates content language (the module’s contract keeps every language selection in the URL). Send langCode instead.
Next steps
Section titled “Next steps”- Translating content: create, update, and delete single translations with the same
langCodeparameter. - Querying guide: filters, sparse fieldsets, and includes all combine with the language selection unchanged.
- Content model reference: which fields are translatable on each type, and how translated fields appear in the JSON.
- Fetch content directly: where the locale-mapping function from step 6 plugs into a real app’s data module.
Was this page helpful?
What went wrong?
Still stuck? Contact Acquia Support (opens in a new tab)