Internationalization in Next.js with React-i18next [REPO]

In today’s tutorial we are going to see how to configure Next.js to display your content in multiple languages to support the internationalization process of your product.

You can see the code on GitHub and the project deployed in codesandbox:

https://codesandbox.io/p/github/juannunezblasco/nextjs-localized-i18n/main

https://github.com/juannunezblasco/nextjs-localized-i18n

To carry out the implementation, I am going to use three libraries that I think are great:

  • react-i18next: This is a super powerful solution for internationalizing React and Next.js applications. It allows me to easily translate my app into multiple languages and provides me with easy-to-use React components. Best of all, it is very flexible and works on both the server and the client, making it perfect for my Next.js projects.
  • i18next-resources-to-backend: This library allows me to upload and save translations to the backend server. This is very useful, especially in large projects where I need to manage translations centrally and load them dynamically as needed. Additionally, it helps me improve performance by reducing the amount of data I need to load to the client.
  • i18next-browser-languagedetector: This is an extension for i18next that detects the user’s language in the browser. It’s great because it allows me to automatically set the app’s language based on the user’s language settings. The library supports language detection through various methods, including browser language, cookies, URL, etc.

i18n global configuration

// i18n/index.ts
import { createInstance } from 'i18next'
import resourcesToBackend from 'i18next-resources-to-backend'
import { initReactI18next } from 'react-i18next/initReactI18next'
import { getOptions } from './settings'

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

const initI18next = async (lng: string, ns?: string) => {
  const i18nInstance = createInstance()
  await i18nInstance
    .use(initReactI18next)
    .use(resourcesToBackend((language: string, namespace: string) => import(`./locales/${language}/${namespace}.json`)))
    .init(getOptions(lng, ns))
  return i18nInstance
}

The code block above shows how to configure internationalization in Next.js using the i18next library. A function useTranslation is defined that initializes an instance of i18next with the specified language and namespace. This function returns an object with a translation function t and the instance of i18next.

The initI18next function is responsible for creating and initializing the i18next instance, using the provided language and namespace. This function uses initReactI18next to integrate i18next with React and resourcesToBackend to load translations from the backend server. The options for i18next initialization are obtained from the getOptions function.

// i18n/settings.ts 
	
export const fallbackLng: string = 'es'
export const languages: string[] = [fallbackLng, 'en']
export const cookieName: string = 'i18next'
export const defaultNS: string = 'translation'

export function getOptions (lng = fallbackLng, ns = defaultNS) {
  return {
    // debug: true,
    supportedLngs: languages,
    fallbackLng,
    lng,
    fallbackNS: defaultNS,
    defaultNS,
    ns
  }
}

In the code above, I set i18n. I defined a couple of constants: fallbackLng, which is the language we use by default, and languages, an array with all the languages we use. I think it would be more flexible if we took these values from the environment variables.

I also defined cookieName, which is how we call the cookie where we save the selected language, and defaultNS, which is our default namespace. In the end, I have a function getOptions that returns an object with all the options we will use to initialize i18n.

Just a note: normally, in a production environment, we would define these constants as environment variables. In this way, we can have more control and security, since they can vary depending on the environment and would not be visible in the code. But, to make it easier to understand, I have left them written directly in the code.

// i18n/client.ts

'use client'

import { useEffect, useState } from 'react'
import i18next from 'i18next'
import { initReactI18next, useTranslation as useTranslationOrg } from 'react-i18next'
import { useCookies } from 'react-cookie'
import resourcesToBackend from 'i18next-resources-to-backend'
import LanguageDetector from 'i18next-browser-languagedetector'
import { getOptions, languages, cookieName } from './settings'

const runsOnServerSide = typeof window === 'undefined'

// 
i18next
  .use(initReactI18next)
  .use(LanguageDetector)
  .use(resourcesToBackend((language: string, namespace: string) => import(`./locales/${language}/${namespace}.json`)))
  .init({
    ...getOptions(),
    lng: undefined, // let detect the language on client side
    detection: {
      order: ['path', 'htmlTag', 'cookie', 'navigator'],
    },
    preload: runsOnServerSide ? languages : []
  })



export function useTranslation(lng: string, ns?: string, options: any = {}) {
  const [cookies, setCookie] = useCookies([cookieName])
  const ret = useTranslationOrg(ns, options)
  const { i18n } = ret
  if (runsOnServerSide && lng && i18n.resolvedLanguage !== lng) {
    i18n.changeLanguage(lng)
  } else {
    // eslint-disable-next-line react-hooks/rules-of-hooks
    const [activeLng, setActiveLng] = useState(i18n.resolvedLanguage)
    // eslint-disable-next-line react-hooks/rules-of-hooks
    useEffect(() => {
      if (activeLng === i18n.resolvedLanguage) return
      setActiveLng(i18n.resolvedLanguage)
    }, [activeLng, i18n.resolvedLanguage])
    // eslint-disable-next-line react-hooks/rules-of-hooks
    useEffect(() => {
      if (!lng || i18n.resolvedLanguage === lng) return
      i18n.changeLanguage(lng)
    }, [lng, i18n])
    // eslint-disable-next-line react-hooks/rules-of-hooks
    useEffect(() => {
      if (cookies.i18next === lng) return
      setCookie(cookieName, lng, { path: '/' })
    }, [lng, cookies.i18next])
  }
  return ret
}

Finally, in i18n/client.ts, I fine-tuned the client part of the internationalization. Here, I initialized i18next with react-i18next to integrate with React, LanguageDetector to detect the user’s language in the browser, and resourcesToBackend to load the translations from the backend server. I also defined a function useTranslation that is responsible for changing the language and saving the selected language in a cookie.

React component to change language

I show you a React component that I called LanguageSwitcher. This component allows you to change the language of the app. You will see that it first shows the language you are using and when you click on it, it displays a list with all the available languages. When you choose one from the list, the app changes to the language you selected.

'use client'

import { useState } from 'react'
import Link from 'next/link'
import { FalChevronRight } from '@/assets/icons/FalChevronRight'
import { languages } from '../settings'

export const LanguageSwitcher = ({ locale, className }: { locale: string, className?: string }) => {

  const [showLocales, setShowLocales] = useState(false)

  const handleShowLocales = () => {
    setShowLocales(!showLocales)
  }

  return (
    <div className={'relative cursor-pointer ml-3 LanguageSwitcher' + className } onClick={handleShowLocales}>
      <div
        className="text-center cursor-pointer "
        // className="route.params.url == item.url ? 'border-white' : 'border-transparent'"
      >
        <div className="flex items-center justify-between">
          <span className="mr-1">{ locale }</span>
          <span className="transition-all">
            <FalChevronRight
              className={"h-3 fill-white rotate-90 transition-all duration-150 " + (showLocales && '-rotate-90')} />
          </span>
        </div>
      </div>
      {
        showLocales &&
          <div className="flex flex-col absolute shadow-md bg-white">
            {languages.filter((l) => locale !== l).map((l) => {
              return (
                <Link className='hover:bg-black-300/10 px-2 py-1 text-black' key={l} href={`/${l}`}>
                  {l}
                </Link>
              )
            })}
          </div>
      }
    </div>
  )
}

Now the only point we are missing is to include a middleware to redirect all those who try to enter our website without including the language string that we have set.

This code shows how we use middleware in Next.js to handle URL redirects based on the app’s language. The middleware checks if you have set a cookie with the current language. If it exists, it outputs the language from the cookie. If it does not exist, remove the language from the browser’s 'Accept-Language' header. If none of these options give a language, set a default language.

The middleware then checks if the request URL includes a language we support. If not, and the URL is not one of Next.js’s internal routes (such as ‘/_next’), redirect the request to a URL that includes the language we selected.

Additionally, if the ‘referer’ header of the request contains a language that we support, this language is saved in a cookie. This can be useful for remembering the user’s preferred language between different sessions.

// middleware.ts

import { NextResponse, NextRequest } from 'next/server'
import acceptLanguage from 'accept-language'
import { cookieName, fallbackLng, languages } from './modules/i18n/settings'

export const config = {
  matcher: ['/((?!api|_next/static|_next/image|assets|images|styles|icons|favicon.ico|sw.js).*)']
}

export async function middleware(request: NextRequest) {

  const response = NextResponse.next()

  let locale
  if (request.cookies.has(cookieName)) locale = acceptLanguage.get(request.cookies.get(cookieName)?.value)
  if (!locale) locale = acceptLanguage.get(request.headers.get('Accept-Language'))
  if (!locale) locale = fallbackLng

  // Redirect if locale in path is not supported
  if (
    !languages.some(locale => request.nextUrl.pathname.startsWith(`/${locale}`)) &&
    !request.nextUrl.pathname.startsWith('/_next')
  ) {
    return NextResponse.redirect(new URL(`/${locale}${request.nextUrl.pathname}`, request.url))
  }

  if (request.headers.has('referer')) {
    const refererUrl = new URL(request.headers.get('referer') ?? '')
    const lngInReferer = languages.find((locale) => refererUrl.pathname.startsWith(`/${locale}`))
    const response = NextResponse.next()
    if (lngInReferer) response.cookies.set(cookieName, lngInReferer)
    
  }

  return response

}

Conclusions

¡Voilà! We just showed you how to implement internationalization in your Next.js app with react-i18next, i18next-resources-to-backend, and i18next-browser-languagedetector.

Now your app is ready to conquer the world, that sounds great! If this tutorial has been useful to you, why don’t you share it on your networks? I’m sure it can help your fellow developers.

Juan Núñez Blaco

Full Stack Developer