Skip to content

Handling Translated Content

The JSON:API Client offers a number of features to make it easy to work with localized content provided by Drupal. Let’s adapt our grid of recipes to make use of content translated in both English and Spanish from the Umami Demo profile.

In our previous examples we have not specified a locale, which means that the default locale was used. If you want to work with content in a different locale by default, you can specify the defaultLocale option when creating an instance of the client.

---
import { JsonApiClient } from "@drupal-api-client/json-api-client";
import { Jsona } from "jsona";
const client = new JsonApiClient(
"https://drupal-api-demo.party", {
serializer: new Jsona(),
defaultLocale: "es",
},
);
const recipes = await client.getCollection("node--recipe", {
queryString: "include=field_media_image.thumbnail",
});
---
<style>
.card-grid {
display: grid;
grid-template-columns: 1fr 1fr;
grid-gap: 1rem;
}
.card-grid .card {
display: flex;
flex-direction: column;
justify-content: space-between;
border: 2px solid var(--sl-color-text-accent);
margin-top: 0;
padding: 0.5rem;
}
</style>
<div>
<h2>Umami Recetas</h2>
<div class="card-grid">
{recipes.map((recipe) => (
<div class="card" key={recipe.id}>
<h4>{recipe.title}</h4>
<div>
<p>Dificultad: {recipe.field_difficulty}</p>
<img
src=`${client.baseUrl}${recipe.field_media_image.thumbnail.uri.url}`
alt={recipe.field_media_image.thumbnail.resourceIdObjMeta.alt}
/>
<a href={recipe.path.alias}>Ver Receta</a>
</div>
</div>
))}
</div>
</div>

By providing the defaultLocale option, we can now work with content in Spanish by default. As we’ll see next, it is still possible to specify a different locale when when making individual requests.

Using our client defaulted to Spanish, we can still request content in English when necessary. By specifying the locale option with getResource we can override the default locale for individual requests.

---
import { JsonApiClient } from "@drupal-api-client/json-api-client";
import { Jsona } from "jsona";
const client = new JsonApiClient(
"https://drupal-api-demo.party", {
serializer: new Jsona(),
defaultLocale: "es",
},
);
const recipes = await client.getCollection("node--recipe", {
queryString: "include=field_media_image.thumbnail",
locale: "en",
});
---
<style>
.card-grid {
display: grid;
grid-template-columns: 1fr 1fr;
grid-gap: 1rem;
}
.card-grid .card {
display: flex;
flex-direction: column;
justify-content: space-between;
border: 2px solid var(--sl-color-text-accent);
margin-top: 0;
padding: 0.5rem;
}
</style>
<div>
<h2>Umami Recipes</h2>
<div class="card-grid">
{recipes.map((recipe) => (
<div class="card" key={recipe.id}>
<h4>{recipe.title}</h4>
<div>
<p>Difficulty: {recipe.field_difficulty}</p>
<img
src=`${client.baseUrl}${recipe.field_media_image.thumbnail.uri.url}`
alt={recipe.field_media_image.thumbnail.resourceIdObjMeta.alt}
/>
<a href={recipe.path.alias}>View Recipe</a>
</div>
</div>
))}
</div>
</div>

A localized request sends the language twice: as the /{locale}/ URL path prefix the examples above show (how stock Drupal negotiates), and as a langCode query parameter (?langCode=es). Stock Drupal ignores the parameter; a backend using jsonapi_multilingual selects the translation with only the parameter. The same call therefore works on either kind of backend with no configuration or detection. Writes target exactly one translation the same way: updateResource and deleteResource send the prefix plus langCode (a DELETE with a locale removes only that translation; without one it deletes the whole entity).

A backend with jsonapi_multilingual treats langCode strictly: it serves exactly the translation you asked for and never falls back silently. When that translation does not exist, an individual read answers 404 Not Found and a collection excludes the untranslated items, reporting them under meta.omitted. The 404 advertises the translations that do exist (filtered to those you may view), and getAvailableTranslations extracts them:

import { getAvailableTranslations } from "@drupal-api-client/json-api-client";
const { response, json } = await client.getResource("node--recipe", id, {
locale: "fr",
rawResponse: true,
});
if (response.status === 404) {
// For example ["en", "es"]: retry with one, or render a language switcher.
const available = getAvailableTranslations(json);
}

To always get content instead of a strict miss, opt the read into the site’s language fallback chain with includeFallback:

await client.getResource("node--recipe", id, {
locale: "fr",
includeFallback: true,
});

The backend resolves the best available translation through the same fallback mechanism the rendered site uses (so fallback-policy modules like language_hierarchy apply automatically): an individual read of an existing entity always succeeds, and a collection includes every item, each in its best available language. The resource’s langcode attribute reports the language actually served. The site owns the fallback policy — the client only opts in. On the wire this is ?langCode=fr&includeFallback=1; a backend without jsonapi_multilingual ignores it.

createTranslation adds a translation to an existing entity — distinct from createResource, which creates a new entity. The payload’s langcode attribute must match the targeted locale, and creating a translation that already exists is rejected with a 409 conflict (use updateResource for that):

await client.createTranslation("node--recipe", id, body, { locale: "es" });

The /{locale}/ prefix exists for backwards compatibility with URL path-prefix negotiation. On a backend with jsonapi_multilingual, langCode carries the language on the canonical URL, so the prefix is redundant there; setting languagePathPrefix: false is recommended for those sites, and required for any backend that does not serve /{locale}/ URLs at all.

const client = new JsonApiClient("https://drupal.example.com", {
languagePathPrefix: false,
});

The option applies to every request the client makes, including the decoupled_router hop of getResourceByPath, which then asks for /router/translate-path instead of /{locale}/router/translate-path. A translated path alias resolves from the path alone, and a backend with jsonapi_multilingual reports the language it resolved as entity.langcode; getResourceByPath passes that language to the follow-up JSON:API read (unless you pass an explicit locale), so getResourceByPath("/bonjour-le-monde") returns the French translation the path names, with no URL prefix involved.

All of our examples thus far have dealt with collections of multiple resources. Let’s next see the ways we can use the client to retrieve only a single resource.