Skip to content

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.

  • 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.
  • A working token: the auth quickstart sets this up, including the .env file 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 its Translate tab, 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.
  1. In your site’s admin UI, Configuration > Region and language > Languages lists every enabled language (administrator-level; an editor account sees the available languages on the Translations screen instead, observed 2026-08-19). Each has a langcode (en, es, hi) and those exact codes are what the API speaks: they appear as the langcode attribute in every response, and they are the values the langCode query parameter accepts.

    Mind the casing, because two different names are one letter apart: langcode (all lowercase) is the response attribute, and langCode (camelCase) is the query parameter. The API rejects a lowercase ?langcode= query parameter with 400 (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 translation switched on under Structure > Content types > your type > Language settings, and the translatable fields are checked under Configuration > Region and language > Content language and translation.

  2. Add ?langCode= to the entry’s URL. The parameter is strict: you get exactly the translation you asked for, or a 404 naming 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=fi returned the Finnish translation with Content-Language: fi once it existed, and the strict 404 before it did.

    Pass the parameter as a query string (the Drupal API Client configured as in the query quickstart; id is the entry’s UUID):

    const entry = await client.getResource(
    "node--article",
    id,
    { queryString: "langCode=es" },
    );
    console.log(entry.data.attributes.langcode); // expect: es

    A successful response returns the translation. The Content-Language response header and the langcode attribute both name the language served, and under a bare langCode that 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 404 whose error object advertises the translations you may view: human-readably in errors[0].detail and machine-readably as a sorted array in errors[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: 422 with The specified language ("de") is invalid or has not been configured. (captured 2026-08-19).

  3. When you would rather have some translation than a 404, add includeFallback=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 returns 200. Verified live (2026-08-19): the same request that 404ed strictly returned 200 with the default translation and Content-Language: en under 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-Language header and the langcode attribute 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;

    includeFallback is valid only together with langCode, only on reads, and only with the literal value 1. The rejections are 400s with self-explanatory details (captured 2026-08-19): The "includeFallback" query parameter requires the "langCode" query parameter. and The "includeFallback" query parameter only accepts the value "1".

    Keep the failure shapes apart:

    • Missing translation, strict read: 404 carrying meta.availableTranslations. The entry exists; that translation doesn’t.
    • Fallback content: 200, but langcode isn’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: 404 with no availableTranslations, or "data": [] for a collection: the entry itself doesn’t exist (or isn’t published). No language will help.
  4. 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 on related reads and on includes (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, because Content-Language reports per-entry only on single-entry responses.

    On a site with one Finnish translation among three entries, ?langCode=fi returns exactly the translated entry in Finnish (meta.count: 1), and ?langCode=fi&includeFallback=1 returns all three, one fi and two en.

    const entries = await client.getCollection(
    "node--article",
    { queryString: "langCode=es&includeFallback=1" },
    );
    const exact = entries.data.filter(
    (entry) => entry.attributes.langcode === "es",
    );

    A language with no matching entries returns "data": [], not an error: the missing-content shape from step 3.

    An include honors the same selection: under strict langCode, an included resource with no translation in that language is left out of included and reported under meta.omitted, its per-item detail reading No translation of the included resource matches the request's language selector. Available translations: en.; under fallback it is chain-resolved like everything else. The meta.omitted wrapper’s own top-level detail is 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]=fi matches 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.

  5. 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’s locale option (client.getResource(type, id, { locale: "es" })) and its defaultLocale constructor 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 a 404 either 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-Language never negotiates content language regardless of mechanism (the module’s reads ignore language request headers by contract).

  6. 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=1

    The 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 includeFallback is 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 a 404 for your route handler to turn into your own not-available treatment.

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.

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.

  • Translating content: create, update, and delete single translations with the same langCode parameter.
  • 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?