Building localized NextJS application with language selector and cloud hosted translations: Part 1 / 2 - server side

Image by macrovector on Freepik

The code for this article can be found here: https://github.com/epanikas/nextjs-tailwind-language-selector

The second part of this article, discussing client-side language switching, can be found here: epanikas.hashnode.dev/building-localized-nextjs-application-with-language-selector-and-cloud-hosted-translations-part-2-2-client-side

In this blog post, we will be building a NextJS application with a language selector, while all the translations will be hosted and managed by a centralized dedicated backend provided by the site SimpleLocalize.io.

It turns out that there are several options for hosting and managing your web app localization, LOCIZE.com and SimpleLocalize.io, to name a few. Probably there are others, but I didn't go that far.

My choice of SimpleLocalize was rather random and sporadic: when I was searching for an example of a language switcher for a web app, I stumbled upon this blog post: https://simplelocalize.io/blog/posts/create-language-selector-with-nextjs-and-tailwind/, which gave me a good starting point.

The code described in this article didn't work for me quite well (hence this post), however by then I had already created an account on the site and was seduced by the possibility of automatic translation (powered by Google-Translate or DeepL), built-in CDN and regular push of missing keys to the backend for further translations.

The pricing options of SimpleLocalize.io are quite attractive as well, offering a free developer-friendly pricing plan. As compared to Locize.com, where the cheapest plan is 5$/month, which is probably well-justified for their functionality. But I was not ready for that much of an investment... yet.. for my small hobby project .. :)

Ok, enough of prehistory, let's start coding

Step 1: forking a project

As has been mentioned above, I have taken the code provided by this blog post as a starting point. We'll start by forking an existing GitHub repo: https://github.com/simplelocalize/nextjs-tailwind-language-selector

If you, like me, were thinking that this is where the journey ends, and you'll have your pretty language switcher, say no more, behold, this is the result:

Well.. it's a bit... disappointing... , don't you think? So far nothing there - an empty page. So let's dig down further.

Step 2: making adjustments for new NextJS

Let's have a look first of all at the file structure of the project

If you think that this is an old NextJS application file structure, you are absolutely right. Apparently, NextJS had a major release - NextJS 13 - which has changed the file structure of the project to root all the pages and routes under the "app" folder.

So, let's first adjust our project to follow this new file structure:

Note, that a new file - layout.tsx - has been added, and the file index.tsx has been renamed to page.tsx. All this to be conformant with the new NextJS file structure. A few adjustments have also been made to the config files: next.config.js, package.json and tsconfig.json

Step 3: client and server components

I had also to add "use client" to LanguageSelector.tsx, since Next was complaining (rightfully) about such methods as useEffect and useState, that are only available for client components. Indeed, it turns out that the new NextJS is treating any component as a server component by default unless explicitly specified otherwise.

However, running this updated project gives an even worse result:

 ⨯ TypeError: (0 , react__WEBPACK_IMPORTED_MODULE_0__.createContext) is not a function
    at eval (webpack-internal:///(rsc)/./node_modules/react-i18next/dist/es/context.js:22:73)
    at (rsc)/./node_modules/react-i18next/dist/es/context.js ([project folder]\nextjs-tailwind-language-selector\.next\server\vendor-chunks\react-i18next.js:210:1)
    at __webpack_require__ ([project folder]\nextjs-tailwind-language-selector\.next\server\webpack-runtime.js:33:42)
    at eval (webpack-internal:///(rsc)/./node_modules/react-i18next/dist/es/Trans.js:8:69)
    at (rsc)/./node_modules/react-i18next/dist/es/Trans.js ([project folder]\nextjs-tailwind-language-selector\.next\server\vendor-chunks\react-i18next.js:180:1)
    at __webpack_require__ ([project folder]\nextjs-tailwind-language-selector\.next\server\webpack-runtime.js:33:42)
    at eval (webpack-internal:///(rsc)/./node_modules/react-i18next/dist/es/index.js:26:67)
    at (rsc)/./node_modules/react-i18next/dist/es/index.js ([project folder]\nextjs-tailwind-language-selector\.next\server\vendor-chunks\react-i18next.js:240:1)
    at __webpack_require__ ([project folder]\nextjs-tailwind-language-selector\.next\server\webpack-runtime.js:33:42)
    at eval (webpack-internal:///(rsc)/./src/app/page.tsx:16:71)
    at (rsc)/./src/app/page.tsx ([project folder]\nextjs-tailwind-language-selector\.next\server\app\page.js:297:1)
    at Function.__webpack_require__ ([project folder]\nextjs-tailwind-language-selector\.next\server\webpack-runtime.js:33:42)
    at runNextTicks (node:internal/process/task_queues:60:5)
    at listOnTimeout (node:internal/timers:540:9)
    at process.processTimers (node:internal/timers:514:7)
    at async e9 ([project folder]\nextjs-tailwind-language-selector\node_modules\next\dist\compiled\next-server\app-page.runtime.dev.js:35:396554)    
    at async tb ([project folder]\nextjs-tailwind-language-selector\node_modules\next\dist\compiled\next-server\app-page.runtime.dev.js:35:400212)    
    at async tS ([project folder]\nextjs-tailwind-language-selector\node_modules\next\dist\compiled\next-server\app-page.runtime.dev.js:35:400773)    
    at async tS ([project folder]\nextjs-tailwind-language-selector\node_modules\next\dist\compiled\next-server\app-page.runtime.dev.js:35:400904)    
    at async tR ([project folder]\nextjs-tailwind-language-selector\node_modules\next\dist\compiled\next-server\app-page.runtime.dev.js:36:2130)      
    at async [project folder]\nextjs-tailwind-language-selector\node_modules\next\dist\compiled\next-server\app-page.runtime.dev.js:36:2722 {
  digest: '2724963577',
  page: '/'
}
 GET / 500 in 361ms

Wow, that is quite a cryptic error.

The error was so startling that I didn't even begin to analyze it. Instead, I tried to figure out which part of the code could be causing this problem, and it turned out that the error was caused by using the useTranslation method of react-i18next in page.tsx.

import {useTranslation} from "react-i18next";
...
const {t} = useTranslation();
...
t('TITLE', {defaultValue: "Language selector demo"})

Let's comment out for now the offending lines, and let's mock the function t(...) to get rid of the compilation problems:

const t = (key: string, options: any): string => key;

With these modifications in place, we can at least start our application:

Step 4: connecting to a backend

Here the time has come for us to create a cloud backend, and connect our app to it.

As has been mentioned earlier, we will be using the SimpleLocalize.io backend.

Creating an account and choosing the free plan is quite straightforward, so no need to describe it here.

At some point, you should end up on the dashboard, from where you would head to Integrations -> Project credentials

Copy the API key and Projet Token to the dedicated places in i18n.ts - src/app/i18n/i18n.ts

export const projectToken = ""; // YOUR PROJECT TOKEN
export const apiKey = ""; // YOUR API KEY

Cool! Finally! We can at least see what this long-awaited language selector would look like! )) Frankly speaking... not bad!

Taking a few steps back

So far we have taken a shortcut to make the app start. In particular, we have completely disabled the localization possibilities provided by i18next: we completely disabled the original useTranslation hook and replaced the funciton t() by a mock to avoid compilation problems.

// import {useTranslation} from "react-i18next";
...
// const {t} = useTranslation();
...
const t = (key: string, options: any): string => key;

Let's try to put the required code back in, however, this time, let's declare the whole component defined in page.tsx as a client component.

// in LanguageSelector.tsx
"use client"
// in page.tsx
"use client"

What we have done here is to match the mode of the page.tsx component - server by default - to the mode of the LanguageSelector, which is used by it - client. Note that according to the NodeJS documentation, it is perfectly legal for server components to use client components. But in our case, something else went wrong, probably due to the peculiarities of the react-i18next library.

Let's try it out and see what happens.

Great, we have our language selector, and even the translated text. If we try to switch the language to German, for example:

we will get the correct translation.

Ok, great, so far, so good. Not only is the language translator working properly, but it is also switching the language!

So, we are done, right? Everything works as expected, and nothing else to fix?

Weeeell... not exactly.

Two major disadvantages of the previous solution can be emphasised (actually one drawback, and one bug!):

  • the drawback: the translation is being performed on the client side, thus ignoring all the benefits of the static page generation, provided by NextJS

  • the bug: but most importantly, our page won't work if we simply reload the page!

If we try to hit F5 on the page, when any language, but English, is chosen, we will get the following error:

Ah ! What an unexpected plot twist! After all, we were so close...

Dealing with the error: Text content does not match server-rendered HTML.

Here is the official explanation of the error from the NextJS documentation:

Why This Error Occurred

While rendering your application, there was a difference between the React tree that was pre-rendered from the server and the React tree that was rendered during the first render in the browser (hydration).

Hydration is when React converts the pre-rendered HTML from the server into a fully interactive application by attaching event handlers.

Indeed, this error can have many different causes, but apparently, the reason boils down to this:

the HTML pre-rendered for a client component on the server side, doesn't match the HTML for this client component, rendered by React on the client side

For detailed (and quite lengthy!) explanation on what is a server component and client component please see this link: Building Your Application: Rendering | Next.js (nextjs.org)

What is important for us here is the fact that the client-component, provided by NextJS, is not rendered entirely on client side. Instead, apparently, there is some portion of this component, that is already pre-rendered on the server side. Then this portion, along with the relevant piece of Java Script code, is being sent to the browser. On the client side the pre-rendered HTML and the supplied JavaScript are kind of boiled together to form a fully functional piece of the web app.

"Hydration" is a process of React when the DOM is being constructed using the JS. Normally this hydration process starts from an empty DOM. However, in case of NodeJS apparently the React hydration doesn't start from an empty DOM, but rather from a DOM that has already been pre-constructred by NextJS on the server side.

Here is the illustrating schema of this process, presented here:

What happens in our case is, apparently, the following:

  • the Home component, defined in page.tsx, is marked as client, but still is being pre-rendered on the server side

  • the rendering on the server side takes place when no language is selected, hence the fallback language is chosen, which is english

  • the page is pre-rendered using the english translation, and sent to the client, along with the relevant piece of JavaScript code, which in particular includes the i18n configuration from i18n/i18n.ts

  • thanks to the plugin LanguageDetector, on the client side the JavaScript executes and detects the cookie, containing a language definition, that is not English (German, in the example above)

  • since the German language is selected, the German translations are being loaded by React, and the relevant places of the HTML are replaced

  • in particular, instead of "Language selector demo" we are having "Demo zur Sprachauswahl"

  • which is why the error is shown

In other words, according to NextJS, the page rendered on the server side should match the page rendered on the client side, at least as far as the static text is concerned. Full stop.

If we remove the LanguageDetector plugin from the config of i18next, we won't have the error anymore.

i18n
    .use(Backend)
//    .use(LanguageDetector) // commented out to disable language detection
    .use(initReactI18next)
    .init({...});

But of course in this case the user experience will be sub-optimal, as every time after the page reload the English version will be shown, even if another language has been chosen before.

Recall, that everything in this world comes at a price... and this is the price to pay for converting the entire application into the client component (remember putting "use client" to page.tsx ? ).

Step 5: leveling things up - going entirely static (almost)

Now, ironically, the error we just discussed takes us in the right direction! Instead of creating the pages on the client side, you can - and should! - have all the static content pre-rendered on the server side.

In this way, we not only get rid of the error, but also make our application much more performant, and above all in the spirit of the NextJS philosophy: go static as long as you can until you can not! (sorry, I only invented that motto for this blog post :-) )

To pursue this new challenge we'll need to refer to another blog article, this time from LOCIZE.com: https://locize.com/blog/next-app-dir-i18n/

The main idea described in this article can be summarized like this:

Create a static page routing, having the language - lng - as a parameter, and pre-render a page for each language on the server side. Then the language switching becomes merely a matter of following the corresponding hyperlink.

In other words, instead of having a url https://my-site.com/my-route, we will be having urls per language: https://my-site.com/en/my-route, https://my-site.com/de/my-route, etc. This way the displayed language will be predefined by the url the user is viewing currently.

Restructuring the code tree

First, let's create a folder [lng] in our code source tree:

This new parameter - lng - will be dealt with in the following way:

...
import {useTranslation, allLanguages} from "@/app/i18n/server-i18n-conf";

export async function generateStaticParams(): Promise<LanguageDef[]> {
    return await allLanguages();
}

export default async function Home({params}: {params: {lng: string} }): Promise<JSX.Element> {
    const { lng } = params;
    const { t } = await useTranslation(lng);
    ...
}

Note, that our component becomes async, as we will be fetching data from the cloud, which hosts the translations, during rendering.

Note also, that the useTranslation hook has also been replaced by the custom useTranslation, defined in server-i18-conf.ts. This new useTranslation function, unlike the previous one, which didn't take arguments, takes at least one argument - the language that is used to display the page.

This point is very important here, this is exactly why our page is being rendered in the language predefined in advance.

What is probably worth noticing here is the function generateStaticParams, which would inform NextJS of all the languages for which the page should be rendered.

Defining i18next configurations: server-side

Since now our page rendering is happening entirely on the server side, we should adapt our configuration of i18next so that it will suit the server-side usage.

Have a look at the file server-i18n-conf.ts:

...
import resourcesToBackend from 'i18next-resources-to-backend'

export const projectToken = "YOUR PROJECT TOKEN";
export const apiKey = "YOUR API KEY";
export const cdnBaseUrl = "https://cdn.simplelocalize.io";
export const environment = "_latest"; // or "_production"

export const loadPathBase = `${cdnBaseUrl}/${projectToken}/${environment}`;
export const loadPathLng = `${loadPathBase}/{{lng}}`;

const runsOnServerSide = typeof window === 'undefined'

if (!runsOnServerSide) {
    throw new Error("this config is intended on server side only")
}

const translationToResource = async (language: string, ns: string) => {
    const resp = await fetch(`${loadPathBase}/${language}/${ns}`);
    return await resp.json()
}

export const allLanguages = async (): Promise<LanguageDef[]> => {
    const resp = await fetch(`${loadPathBase}/_languages`);
    return await resp.json();
}

const initI18next = async (lng: string, ns?: string): Promise<i18n> => {
    const i18nInstance = createInstance()
    await i18nInstance
        .use(initReactI18next)
        .use(resourcesToBackend(translationToResource))
        .init(getOptions(lng, ns))
    return i18nInstance
}

export async function useTranslation(lng: string, ns?: string, options: {keyPrefix?: string} = {}): Promise<{t: TFunction<any, string>, i18n: i18n}> {
    const i18nextInstance = await initI18next(lng, ns)
    return {
        t: i18nextInstance.getFixedT(lng, Array.isArray(ns) ? ns[0] : ns, options.keyPrefix),
        i18n: i18nextInstance
    }
}

Let's highlight a few points here:

  • we are using the plugin i18next-resources-to-backend, which is an adapter for the Backend plugin of i18next. It's a convenient option for fetching the translations from the cloud

  • the instance of i18next is created each time the method initI18next is called, which is in turn called each time useTranslation is called. This is very important, as the NextJS rendering is happening in parallel for all the pages on all the languages simultaneously. Consequently, the only thread-safe option for us is to have separate instances of i18next (apparently i18next is not thread-safe)

  • the function t(...) is defined as an alias for the getFixedT method from i18next. Unlike the previous version, when the t method has been choosing the language dynamically, this time we are fixing the language to the predefined one.

Modifying the LanguageSwitcher component

The language switcher component will also be modified such that the buttons will act merely as HTML hyperlinks:

export const LanguageSelector = ({languages, selectedLng}: {selectedLng: string, languages: LanguageDef[]}): JSX.Element => {

    const [isOpen, setIsOpen] = useState(true);
    const selectedLanguage = languages.find(language => language.key === selectedLng);

    const handleLanguageChange = async (language: LanguageDef) => {
        window.location.assign(window.location.origin + "/" + language.key);
    };
}

Also note, that in this new version, the LanguageSwitcher receives the selected language and the list of defined languages as parameters. This way we ensure that this client component will have the maximum of its needs in advance, during the rendering phase.

So far we've been moving so fast that we have forgotten one very important aspect - cookie management.

This is quite important as it directly impacts the user experience: imagine you had to choose your preferred language every time you visit the page!

With the hyperlinks-based solution, described above, the language cookie is not managed anymore, nor is it needed, as the language is hardcoded in the URL, and not dynamically selected.

Still, this cookie is important because it might be required for the correct working of other parts of the application.

In addition, we would like our web app to work correctly even if the language is not defined in the URL. For example:

  • https://my-site.com/en/my-route --> brings the English page

  • https://my-site.com/de/my-route --> brings the German page

  • https://my-site.com/my-route --> automatically redirects to the language page according to the 'language' cookie, or the fallback language, if there is no such cookie

This functionality can be achieved using NextJS middleware and the language cookie.

Consider the following modification in LanguageSelector:

    const handleLanguageChange = async (language: LanguageDef) => {
        document.cookie = "language=" + language.key + "; path=/";
        window.location.reload();
    };

Instead of redirecting to the page, we set the language cookie to the value, requested by the user, and reload the page. We let the NextJS middleware handle the routing.

Here is the middleware.ts

import {NextRequest, NextResponse} from "next/server";

// let's hardcode the available languages for now...
const languages = ['en', 'fr', 'de', 'ru']

export async function middleware(request: NextRequest) {

    const existingLang = request.nextUrl.pathname.substring(1, 3);

    const hasLang = languages.find(l => l == existingLang)

    let newLang = 'en';
    const langCookie = request.cookies.get("language");
    if (langCookie) {
        newLang = langCookie.value;
    }

    if (existingLang == newLang) {
        return NextResponse.next();
    }

    const path: string = hasLang ? request.nextUrl.pathname.substring(3) : request.nextUrl.pathname;

    return NextResponse.redirect(`${request.nextUrl.origin}/${newLang}/${path}`);
}
...

The method, presented above, is called at every request in NextJS. Each time we will be analyzing the path of the URL to check if contains the hardcoded language, such as /en/ or /de/. If it does, and the cookie language, if defined, contains the same language, nothing should be done, and we simply return the default NextJS response.

However, if this is not the case, the language should be changed. If the language is not specified in the URL, it is taken from the cookie and the redirect to the correct page takes place.

Conclusion

That's it for this part!

We have created a static per-language page rendering, and we can switch between languages using the static hyperlink-based language switcher.

We have further improved this mechanism by implementing the middleware to allow URLs where the language is not explicitly specified.

However, it remains unclear to what extent the NextJS middleware is performant. Would it be able to handle the high load of production environments?

We haven't discussed in this article a client component, for which language switching would be required.

This is the subject of the second part of this article, that can be found here: epanikas.hashnode.dev/building-localized-nextjs-application-with-language-selector-and-cloud-hosted-translations-part-2-2-client-side

Versions used:

  • Node: v20.13.1

  • Npm: 10.5.2

  • Typescript: 5.4.5

  • NextJS: 14.2.3

  • i18next: 23.11.5

  • React: 18.3.1

0
Subscribe to my newsletter

Read articles from Emzar Panikashvili directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Emzar Panikashvili
Emzar Panikashvili