Next JS app router: how to localise & translate

I migrated my website to the app directory router. However, it’s a multilingual website with content both in French and English, and the translation library I was using, i18next, doesn’t work server-side. So this is the story of how I rolled my own translation system and what I had to set up to make it work.

And even if you don’t need to translate your NextJS website, you might need to use a middleware or a lookup for something else. If you don’t, this article on the new SEO features in Next JS might be interesting to you.

For those of you who are still here, there are three parts to this :

  • first how I structured my app directory to handle locales
  • second, how I coded the lookup function to translate strings within pages
  • third, how I set up the middleware to switch users from one version to the other depending on their browser language.

Let’s dive right in.

Managing languages in Next with the app router

First, how is my app folder structured?

I have a dynamic parameter folder called [lang] at the root of my app folder. This allows me to manage language versions. I have French and English versions and the en/ and fr/routes map to this folder.

Now, if we look at the home page component in the pages.tsx file in the [lang] folder, we can see that the router passes in props containing an object called params. This object contains a member called lang. Reading this allows me to know (in the code) what language is requested, which is the basis for translating.

Translating strings in pages

How do I translate the text in a page, depending on the requested language?

Creating the JSON dictionaries

The first thing I did was to creat two JSON dictionaries, one for English and one for French. The French one is called fr.jsonand the English one is called en.json. I actually used a website called “Localise.biz to edit my translations, and I export my JSON from there.

Loading the JSON dictionaries

Second, at the root of my [lang] directory, I created a dictionnary.tsfile, that requires server-side data, so it starts with :

import 'server-only';

In this file, I start by importing the JSON files :

const dictionaries = {
  en: () => import('./dictionaries/en.json').then((module) => module.default),
  fr: () => import('./dictionaries/fr.json').then((module) => module.default),
};

Let’s start by check on the JSON dictionaries. These are key-value pairs. They allow me to map a (localisation) key to a translated text. So, for example, in the English dictionary, I have the following content:

{
    "menu": {
        "home": "Home",
        "about": "About",
        "blog": "Blog",
        "resources": "Resources"
    },
}

And I have the following content in French:

{
    "menu": {
        "home": "Accueil",
        "about": "À propos",
        "blog": "Blog",
        "resources": "Ressources"
    },
}

This means that menu.homeis “Home” in English, and “Accueil” in French, and menu.aboutis “About” in English and “À propos” in French.

Creating the lookup function

Now, inside the src/folder, I have another folder called utils, and inside that folder, I have a file called i18n.ts. The “i18n” stands for internationalisation, which is just a pedantic way of saying translation.

So the third thing I did, was to write a function called _t Inside the i18nfile. This function takes the localisation key and the JSON dictionary and returns the localised text as a string.

So what does _tdo? You might have noticed that menu.home is not a key in the dictionary. Instead, we have two levels, one under menuand then a homekey underneath that. This is a personal choice because I find it easier to organise my translations that way, but it would work the same if the dictionary were flat.

So in my case, to get the translated value, I first split my localisation key with the “dot” character. And then, I pass the keys array I’ve created to another function called getFromDictionnary, and then return the value.

export const _t = (key: string, dict: Dict): string => {

  const keys = key.split(".");
  return getFromDictionnary(keys, dict);
}

(Well, I also do some additional checks to make sure the values I’m receiving and retrieving are valid, by the basic logic is there).

As you can see, the getFromDictionnary function takes two parameters: first, an array, and second, a value which can be either a string or a dictionary.

If the second value is just a string, the getFromDictionnary returns it directly. If the second value is empty, the function just returns an empty string.

If none of that has happened, the getFromDictionnaryfunction reads (and removes) the first item from the array. And then, the function calls itself using the new shorter array and the value found in the dictionary at the keyentry.

  const key = keys.shift() || '';
  return getFromDictionnary(keys, dict[key]);

This allows me to have multi-stage dictionary lookup and to namespace my localisation keys. If you’re not comfortable with that, you can also go with a flat structure, where every value inside the JSON is a string, with no sub-objects.

The factory function

The fourth step, inside the dictionnary.tsfile I mentioned at the start, was to create a “factory” function. This function takes the language as a parameter and returns a new function, which calls the _tfunction that does the look up with the dictionary specific to the language:

export type Translator = (key:string) => string;

export const getTranslations = async (lang:Lang):Promise<Translator> => {

  const dict = await getDictionary(lang);
  return (key:string) => _t(key, dict);
}

Now, how do we use all that? Well, let’s take a look at my Home page. Inside the page.tsxfile which is at the root of my [lang]dynamic folder, my component retrieves the language parameter via the props. Then, the first thing I do is get the translation lookup function.

const Page =  async ({params : {lang}}: PageProps) => {

  // retrieve the transaction function 
  const t = await getTranslations(lang);

} 

Now, I pass that function down to the different props, so for example if we look inside the <Home>component, we can see that I call the <LatestPosts>component with a translated title and subtitle, which calls the translation lookup function.

<LatestPosts
        variant
        lang={lang} posts={posts}
        title={t('blog.title')}
        subtitle={t('blog.subtitle')}
      />

A quick word on localising blog articles

Now, a quick word: I don’t translate every blog post. The blog articles are just separate pieces of content. However, when a blog post (or a page) is translated, I link the two pages together as alternates in the metadata. Let me know if you want to know more, and I’ll prepare a more in-depth presentation.

Sign up to our newsletter

We help you better understand software development. Receive the latest blog posts, videos, insights and news from the frontlines of web development

We respect your privacy. Unsubscribe at any time.

Managing language versions with the middleware (and switching languages)

Now, I have two versions of my website, one under en/and one under fr/. But what happens when someone just goes to the base website, without going to a sub folder? Well here we need to set up automatic locale detection.

Well here I use another feature of NextJS, a middleware. This is a script that is in a file called middleware.ts. Now this file is not in the appdirectory, it is at the root of the project. The script does three things :

  • First, it checks whether we are in a language subfolder. If we are, then we’re good to go.
  • Second, if not, I use the “Negotiator” npm package to check the headers to see if the browser has indicated the user's preferred locale. Then I use the @formatjs/intl-localematcher npm package to match those to my available languages. If none fit, I just return a default locale, e.g. English.
  • Finally, I return a redirect based on the user's preferred locale added to the start of the url.

The important thing to note here is that the middleware allows us to opt out for certain paths. Typically we don’t want to call this redirection for an api/ route or for the robots file and the site map.

So in the middleware file, we can define a matcher function that uses a RegEx to define which URLs trigger the middleware. This allows us to exclude the different routes.

Hopefully, this will have given you the tools you need to translate your NextJs. Please let me know in the comments if there are any sticking points.

And if you want to learn more about Next JS, you might want to learn how the approuter works or the new SEO functionalities ..

Social
Made by kodaps · All rights reserved.
© 2024