Skip to content

Localization

The whole backend project has a custom localization system integrated.

Sanity provides a plugin for localization, but after some examining, we determined it was not going to work properly with our islands system, and that’s why we came up with the Localized Islands concept.

Each island that contains text inputs can receive a localized property in their payload. If they do, they will then renderize the localized island instead of the regular island, meaning instead of doing defineField directly with their properties, we pass through localized_island/index.js to add the extra language fields we’ll need:

import {defineField} from 'sanity'
import LocalizedIsland from './LocalizedIsland'
import { CustomSlug } from '@attributes/settings/customSlug'
import { languages } from '../utils/languages'
export const defineLocalized = (payload) => {
const {name, title, group, hidden, description, options, type, of, fields, validation, rows, max, required, section_slug, isSlug = false, initialValue, validatedLanguages = null} =
payload
const localizedFields = []
languages.map((lang) => {
localizedFields.push({
name: lang.id,
title: lang.title,
type: type,
...(of && {of}),
...(fields && {fields}),
options,
validation : validatedLanguages ? (validatedLanguages.includes(lang.id) && validation) : (lang.id == 'en' && validation),
rows,
max,
required,
})
})
return defineField({
name,
type: 'object',
title,
group,
hidden,
description,
initialValue,
validation,
options: {
...options,
isFile: type === 'file',
isImage: type === 'image',
isSlug,
collapsed: false,
collapsible: false
},
fields: localizedFields,
components: {
input: isSlug ? (props) => CustomSlug({...props, localized: true, section_slug}) : LocalizedIsland,
},
})
}

This goes through the whole array of languages available in the project, gets the properties from the island, and creates an object for each text field inside the island where instead of having only one text input, we then have one for each language, so a regular title input would go from this:

title: 'This is a title'

To this:

title: {
en: 'This is a title',
es: 'Esto es un título',
it: ...
}

We do need to be careful and reason before we tell an island to be localized or not, since we can end up with fields that we do not want.

For instance, if we tell an asset island to be localized, we will have one image / video per language, which is not usually what we want. If, instead, we tell it to not be, we will have one single image / video but a localized alt text, for instance.

Some islands are not meant to be localized, such as boolean, option or repeater.

Translation in the backend is managed directly via AI. We are currently working on integrating Netwrix’s AI in this process, so we can make calls to an external API to get the translations and patch them into the documents.

Localization is handled in the frontend through several resources.

We use an edge function (netlify/edge-functions/language-middleware) to handle redirections based on language.

We do not use geolocation to determine these redirections, instead if a user lands in a certain language’s page (that is not English) they will have a cookie saved in their browser setting that language as a preferred language.

From then on, every time they access the base URL /en they will be redirected to that language. If the user needs to change the language, they must do it through the language picker at the top left corner of the page.

const url = new URL(request.url);
const { pathname, search } = url;
const withSearch = (path: string) => (search ? `${path}${search}` : path);
// Define allowed languages
const allowedLanguages = ['en', 'es', 'fr', 'de', 'it', 'pt'];
// Extract language from URL path
const pathSegments = pathname.split('/').filter(Boolean);
const firstSegment = pathSegments[0];

We start by saving the search params and defining the allowed languages to redirect. If you need to add languages, this would be one of the places to do so.

We then get the language out of the URL path to use it in our redirection system.

// Handle root path - redirect to preferred language
if (pathname === "/") {
const prefLangCookie = context.cookies.get("preferred_lang");
const target = prefLangCookie && allowedLanguages.includes(prefLangCookie)
? `/${prefLangCookie}/`
: "/en/";
return new Response(null, {
status: 302,
headers: {
Location: withSearch(target),
"Cache-Control": "no-cache, no-store, must-revalidate",
Pragma: "no-cache",
Expires: "0",
Vary: "Cookie",
},
});
}

If we’re in the root, we get the cookie and redirect directly to the home page that corresponds either to that cookie or to English if it does not exist, always preserving the search parameters.

// Only process paths that start with a valid language code
if (!firstSegment || !allowedLanguages.includes(firstSegment)) {
return context.next();
}
const lang = firstSegment;

Weed out invalid languages before proceeding.

// Get the preferred language cookie
const prefLangCookie = context.cookies.get('preferred_lang');
// If the cookie is not set, set it to the language in the url
if (!prefLangCookie) {
const response = await context.next();
response.headers.set('Set-Cookie', `preferred_lang=${lang}; Path=/; Max-Age=31536000`);
return response;
}

If a cookie does not exist, we set it using the language coming from the URL.

if (prefLangCookie && prefLangCookie !== 'en' && lang === 'en') {
const redirectPath = `/${prefLangCookie}${pathname.replace(/^\/en(\/|$)/, '/')}`;
return new Response(null, {
status: 302,
headers: {
'Location': withSearch(redirectPath),
'Cache-Control': 'no-cache',
'Set-Cookie': `preferred_lang=${prefLangCookie}; Path=/; Max-Age=31536000`
}
});
}

We check if we have a non-English cookie and we’re in the English site to perform the redirection.

This component is the one that controls our language picker at the top right corner.

This is linked to a langSwitcher.js class that manages the preferred language cookies.

Each option looks like this:

<ButtonLink payload={{ href: "#", option: 'target_self', customClass: 'c--lang-select-a__wrapper__link js--dropdown-item js--lang-en', label: 'English', "data-no-swup":"" }} />

It’s important to use the data-no-swup attribute so it does not reload the page every time it is used.

The language determined by the URL is received in every template page:

const { lang, slug } = Astro.params;

And sent to the query service:

const content = await the_query({ type:'pages', extraTypes: ['landing_pages'], language: lang, slug: `${slug}`, isDraft });

This retrieves only the portion in that language from each field, for instance, for the title field that looks like this:

title: {
en: 'This is a title',
es: 'Esto es un título',
...
}

It would retrieve directly title.en for the English language:

"title": title.${lang},

Because we use that language in our queries to determine which part of the array should be present in our frontend.

This makes our result queries lighter and takes a lot of processing out of the frontend.

There is a few places where you’ll need to go to add new languages to the site:

utils/aiAssist/prompt.js - the prompt for the action that controls document translation utils/languages.js - the array of languages that feeds into the AI prompt used to translate and the localized islands

netlify/edge-functions/language-middleware.ts - the redirection controller for the site src/scripts/handler/langSwitcher/LangSwitcher.js - the class that controls the lang switcher and src/components/langSwitcher/LangSwitcher.astro to add the option corresponding to the new language to the dropdown itself utilities/languages.js - the array used in other places of the page that contains the list of languages