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

Image by macrovector on Freepik

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

The first part of this article, discussing server-side localization, can be found here: https://epanikas.hashnode.dev/building-localized-nextjs-application-with-language-selector-and-cloud-hosted-translations-part-1-2-server-side

In the previous article, we discussed the problem of a language selector for server-side rendering. We have ended up creating a language selector based on hyperlinks, leading to pre-rendered pages - one per language. We then switch between these pages using HTML hyperlinks or by setting a cookie.

In this article, we will discuss the problem of implementing a client component, which will be interactive, and will also be translated.

Interactive client-side component

First, we will create a simple client component for the sake of this demonstration - translated-counter.tsx:

"use client"

import {useState} from "react";
import {useTranslation} from "react-i18next";

export default function TranslatedCounter(): JSX.Element {

    const { t } = useTranslation('common');

    const [counter, setCounter] = useState(0);

    return (
        <div className={"flex flex-col z-40"}>

            <div>title {t("TITLE")}</div>
            <h3 className={"p-4"}>{t("counter_value")} {counter} {t("items")}</h3>

            <div className={"grid grid-cols-3 gap-2 z-40"}>
                <button
                    className={"p-4 text-center text-black inline-flex rounded-md bg-white border border-red-900 text-xl"}
                    onClick={() => setCounter(counter + 1)}>
                    +
                </button>

                <span className={"text-red-900 text-center"}>{counter}</span>

                <button className={"p-4 text-black inline-flex rounded-md bg-white border border-red-900 text-xl"}
                        onClick={() => setCounter(counter - 1)}>
                    -
                </button>
            </div>
        </div>
    )

}

A straightforward React functional component, defining two buttons and displaying a counter.

Configuring react-i18next

In this component, we are already using the translation - the function t(...).

As a starting point, we will use the function t(...) provided by the react-i18next package - an adapter for i18next. And here comes our first error:

utils.js:7 react-i18next:: You will need to pass in an i18next instance 
by using initReactI18next
warn @ utils.js:7
warnOnce @ utils.js:17
...

Apparently what we should understand from here is the fact that the react-i18next library should be bound to a newly created and initialized instance of i18next. And this binding is happening via passing the hook initReactI18next as the initialization option for i18next.

Ok, let's do that in a newly created i18n configuration file - client-i18n-conf.ts

import i18next from "i18next";
import {initReactI18next} from "react-i18next";

i18next.use(initReactI18next).init()

Then we include this file in the client component

"use client"

import {useState} from "react";
import "@/app/i18n/client-i18n-conf" // <-- included for i18next initialization
import {useTranslation} from "react-i18next";

export default function TranslatedCounter({lng}: { lng: string }): JSX.Element {
...
}

This time the following error is shown:

utils.js:7 react-i18next:: i18n.languages were undefined or empty undefined
...

Once we add the languages by passing the options argument to the init method,

const languages = ['en', 'fr', 'de', 'ru']
i18next
    .use(initReactI18next)
    .init({
        supportedLngs: languages,
        fallbackLng: 'en'
    })

we face another error:

i18next.js:13 i18next::backendConnector: No backend was added via i18next.use. 
Will not load resources.

Let's add a backend by using the already familiar i18next-resources-to-backend adapter:

...
import resourcesToBackend from "i18next-resources-to-backend";

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

i18next
    .use(initReactI18next)
    .use(resourcesToBackend(translationToResource))
    .init({
        debug: true,
        supportedLngs: languages,
        fallbackLng: 'en'
    })

So far we have ended up with the following config (client-i18n-conf.ts):

import i18next from "i18next";
import {initReactI18next} from "react-i18next";
import resourcesToBackend from "i18next-resources-to-backend";

export const projectToken = "your project token";
export const cdnBaseUrl = "https://cdn.simplelocalize.io";
export const environment = "_latest"; // or "_production"

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

const languages = ['en', 'fr', 'de', 'ru']

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

i18next
    .use(initReactI18next)
    .use(resourcesToBackend(translationToResource))
    .init({
        debug: true,
        supportedLngs: languages,
        fallbackLng: 'en'
    })

A portion of this code is related to resource loading, which we have seen already on the server-side config.

This setup of the i18next and react-i18next brings us a few steps further, indeed, but we hit the brick wall again with the following error:

Ah! The old friend... Seems quite familiar, isn't it?

Yepp, indeed, we need to make sure that the rendering on the server side would match exactly the rendering on the client side.

Nailing the language on the client side

To start with, we need to recall that in our application we have a pre-rendered copy of our single page for each language. If this page contains a client component, we need to make sure that when the client component is rendered on the server side, it is pre-rendered in the language of the page.

One might ask: why on earth would a server try to pre-render a client component? Isn't it exactly what the server should NOT try to do? After all, this is what the client components are all about... But let's leave it for now.

Let's specify the language of the page via an argument to the client component:

import {useTranslationClient} from "@/app/i18n/client-i18n-conf";

export default function TranslatedCounter({lng}: { lng: string }): JSX.Element {

    const {t, i18n} = useTranslationClient(lng, 'common');
    ...
}

And let's define the function useTranslationClient in the client-side config as follows:

export function useTranslationClient(lng: string, ns: string)  {
    return useTranslation(ns, {lng});
}

Note the lng argument, that we pass to useTranslation as an option. Here is the explanation according to the react-i18next doc:

This is our way of telling to i18next what language to use when first called on the server side. If we don't do that, the language will be chosen according to the fallback language, defined in the options. And that's not what we want.

With these modifications in place, we have our client component working as expected.

German language

French language

Hiding project token from the client side

A careful reader might have already noticed the fact that the client-side config of i18next has one constant - projectToken. This constant is needed for communication with our translation backend.

This is unwanted, however, since we would like to prevent a piece of sensible information, such as API or project token, from appearing on the client side.

Fortunately, NextJS provides a simple mechanism for defining API routes.

Let's define one route to handle the translation backend. Instead of calling the cloud backend from the client side, for which we'll need the projectToken, we'll simply call an API route, which would perform the call to the backend on the server side for us.

Let's add a new file - route.ts - to the following path: /src/app/api/i18n/[lng]/[ns]

Note the parameters in square braces - [lng] and [ns]

And here is the content:

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

export async function GET(request: NextRequest, { params }: { params: { lng: string, ns: string } }): Promise<NextResponse> {


    const projectToken = process.env.SIMPLELOCALIZE_PROJECT_TOKEN;
    const cdnBaseUrl = "https://cdn.simplelocalize.io";
    const environment = "_latest"; // or "_production"
    const loadPathBase = `${cdnBaseUrl}/${projectToken}/${environment}`;

    const { lng, ns } = params;
    const resp = await fetch(`${loadPathBase}/${lng}/${ns}`);

    if (resp.status == 404) {
        return NextResponse.json("[]", {
            status: 200
        })
    }

    const json =  await resp.json()

    return NextResponse.json(json, {
        status: 200
    })
}

This route will be called from the client side as follows:

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

This implementation is not different from the previous one, only the URL of the backend has changed. But this change would allow us to not use projectToken on the client side anymore.

Unfortunately, this straightforward implementation doesn't give the desired result (to my big surprise, must I say!)

Here is the error:

i18next::backendConnector: loading namespace common for language en 
failed ReferenceError: location is not defined
    at translationToResource (webpack-internal:///(ssr)/./src/app/i18n/client-i18n-conf.ts:34:5)
    at Object.read (webpack-internal:///(ssr)/./node_modules/i18next-resources-to-backend/dist/esm/index.js:13:21)
    at Connector.read (webpack-internal:///(ssr)/./node_modules/i18next/dist/esm/i18next.js:1767:12)
    at Connector.loadOne (webpack-internal:///(ssr)/./node_modules/i18next/dist/esm/i18next.js:1800:10)
    at eval (webpack-internal:///(ssr)/./node_modules/i18next/dist/esm/i18next.js:1784:12)

This error appears due to the way we have defined the URL of the backend:

${location.origin}/api/i18n/${language}/${ns}

Indeed, if we were in the browser, where the global variable window.location is always available, that line would have worked.

However, again we face the situation where the code, destined for the client side, is being executed on the server side for some mysterious reason...

We have no other choice but to compose a code for the i18next client-side configuration, that would work both on the client and on the server side.

The idea is to use the presence (or absence) of the global variable window as an indicator of whether the code is executed on the client or the server side:

const runsOnServerSide = typeof window === 'undefined'

With that flag in place let's redefine our translationToResource method as follows:

export const cdnBaseUrl = "https://cdn.simplelocalize.io";
export const environment = "_latest"; // or "_production"

const translationToResource = async (language: string, ns: string) => {
    let backendUrl;
    if (runsOnServerSide) {
        const projectToken = process.env.SIMPLELOCALIZE_PROJECT_TOKEN;
        const loadPathBase = `${cdnBaseUrl}/${projectToken}/${environment}`;
        backendUrl = `${loadPathBase}/${language}/${ns}`
    } else {
        backendUrl = `${location.origin}/api/i18n/${language}/${ns}`;
    }
    const resp = await fetch(backendUrl);
    return await resp.json()
}

As one can see, when on the server side we are using the real cloud backend, whereas on the client side, we are using the window.location to form a language backend URL, which would lead to the application API route.

Note also the use of the environment variable SIMPLELOCALIZE_PROJECT_TOKEN, which is defined in .env.local as follows:

SIMPLELOCALIZE_API_KEY=your api key
SIMPLELOCALIZE_PROJECT_TOKEN=your project token

The environment variables are only available on the server side. Which is why it is important to use them conditionally. However, the use of environment variables allows us to avoid hardcoding of the sensible information on the client side.

The server-side i18next config can also be modified to make use of these environment variables, instead of hardcoding them (server-i18n-conf.ts):

export const projectToken = process.env.SIMPLELOCALIZE_PROJECT_TOKEN;
export const apiKey = process.env.SIMPLELOCALIZE_API_KEY_;
export const cdnBaseUrl = "https://cdn.simplelocalize.io";

Let's not forget our middleware. We need to add an exception for API routes to our middleware route processing so that the API routes won't follow the language redirect cycle:

export async function middleware(request: NextRequest) {

    if (request.nextUrl.pathname.startsWith("/api")) {
        /*
         * don't redirect for api calls
         */
        return NextResponse.next();
    }

    ... // the rest of the processing goes here
}

Further improvements

As further improvements for the presented solution let's discuss the two following points:

  • language cookie taking precedence over the language in URL, when accessed directly, without using the language selector

  • the hardcoded list of available languages, defined in middleware.ts and client-i18n-conf.ts

In the middleware, we are using the language cookie as a topmost priority language indicator. Hence, even if we directly access the URL where the language is specified - /http:localhost:3000/de/my-route - the middleware will still base its decision on the currently chosen language, taking its value from the language cookie.

That would create a sub-optimal user experience when the application doesn't behave as the user would intuitively expect.

To overcome this problem one possible solution would be to use an additional indicator, for example, a query parameter, set by the language switcher to the URL, before reloading the page.

The language cookie will only be taken into account by the middleware if this additional parameter is present.

The hard coded list of available languages

In three different places in the code, we use the list of available languages:

  • in client-i18n-conf.ts - to configure i18next on the client side

  • in server-18n-conf.ts - also to configure i18next, but on the server side

  • in middleware.ts - to detect the language by searching one of the predefined languages in the URL path prefix

On the server-side config, we are using the method that fetches the languages from the backend. This solution is preferable, as it lets the cloud backend be the single configuration point for our localization configuration.

On the client side, however, the use of async functional components is prohibited:

And consequently, we cannot use the async method allLanguages from server-i18n-conf.ts right away...

This problem is still waiting for a solution!

Conclusion

In this article, we have presented an approach to the localization of a client-side interactive component.

The idea presented in this article is based on an additional argument, with which the interactive component is created. This additional argument instructs the client component about the language that should be used to render the component.

On numerous occasions, during the work on the application, we have encountered problems related to the rendering of the client-component on the server side.

This aspect should be taken into account very carefully when developing localized NextJS applications.

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