# 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

- 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

- A working token: the [auth quickstart](/source-cms/authenticate/quickstart/) sets this up, including the `.env` file used below.
- A [site](/start-here/glossary/#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](https://www.drupal.org/project/jsonapi_multilingual) 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](/start-here/glossary/#jsonapi) before; if not, start with the [query quickstart](/source-cms/content-api/quickstart/).

## Steps

<Steps>

1. ### Find your site's language codes

   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](/start-here/glossary/#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](/start-here/glossary/#content-type-bundle): the type must have `Enable translation` switched on under `Structure > Content types >` *your type* `> Language settings`, and the translatable [fields](/start-here/glossary/#field) are checked under `Configuration > Region and language > Content language and translation`.

2. ### 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 a `404` naming the translations that do exist. Per the [JSON:API Multilingual documentation](https://www.drupal.org/project/jsonapi_multilingual), 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.

   <Tabs syncKey="api-style">
     <TabItem label="JSON:API Client">
       Pass the parameter as a query string (the [Drupal API Client](/start-here/glossary/#drupal-api-client) configured as in the [query quickstart](/source-cms/content-api/quickstart/); `id` is the entry's UUID):

       ```js
       const entry = await client.getResource(
         "node--article",
         id,
         { queryString: "langCode=es" },
       );
       console.log(entry.data.attributes.langcode); // expect: es
       ```
     </TabItem>
     <TabItem label="JavaScript">
       ```js
       // DRUPAL_SITE_URL, access_token, and id as in
       // the query quickstart
       const 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"));
       ```
     </TabItem>
     <TabItem label="curl">
       ```bash
       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"
       ```
     </TabItem>
   </Tabs>

   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:

   ```json
   {
     "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. ### Opt into fallback deliberately

   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:

   ```bash
   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:

   ```js
   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 `400`s 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

   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 `include`s (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`.

   <Tabs syncKey="api-style">
     <TabItem label="JSON:API Client">
       ```js
       const entries = await client.getCollection(
         "node--article",
         { queryString: "langCode=es&includeFallback=1" },
       );
       const exact = entries.data.filter(
         (entry) => entry.attributes.langcode === "es",
       );
       ```
     </TabItem>
     <TabItem label="JavaScript">
       ```js
       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",
       );
       ```
     </TabItem>
     <TabItem label="curl">
       ```bash
       set -a; source .env; set +a

       curl -s "$DRUPAL_SITE_URL/api/node/article?langCode=es&includeFallback=1" \
         -H "Accept: application/vnd.api+json" \
         -H "Authorization: Bearer $TOKEN"
       ```
     </TabItem>
   </Tabs>

   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. ### 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](/start-here/glossary/#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. ### 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:

   ```js
   // 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.

</Steps>

## 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](/source-cms/reference/content-model/). To create, update, or delete one translation over the API, see [translating content](/source-cms/content-api/translating-content/).

## 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

- [Translating content](/source-cms/content-api/translating-content/): create, update, and delete single translations with the same `langCode` parameter.
- [Querying guide](/source-cms/content-api/guide/): filters, sparse fieldsets, and includes all combine with the language selection unchanged.
- [Content model reference](/source-cms/reference/content-model/): which fields are translatable on each type, and how translated fields appear in the JSON.
- [Fetch content directly](/source-cms/content-api/fetch-content/): where the locale-mapping function from step 6 plugs into a real app's data module.
