i18n with Next.js 13 and app directory
Adriano Raiano

Adriano Raiano @adrai

About: Founder, CTO, Software Architect, Bachelor in Computer Science #serverless #nodejs #javascript Always in search for #innovative and #disruptive stuff

Joined:
Apr 14, 2021

i18n with Next.js 13 and app directory

Publish Date: Dec 8 '22
32 32

At Next.js Conf, the Vercel team announced Next.js 13 which introduced the new app directory.


It includes support for Layouts, Server Components, Streaming and Support for Data Fetching.

Awesome! Next.js 13 has been released!


It seems pretty fast and it lays the foundations to be dynamic without limits.

Afterthoughts...

This sounds good, but looking more into the app directory, it looks like this is a complete new Next.js setup... not really comparable to the old one...

What does this mean regarding i18n?

Looking at the docs it seems our old approaches will no work anymore.

not planned

Nice features provided by next-i18next (and other Next.js related i18n modules), like described here and here are not suited to this new app directory setup.

A new approach

In this section you'll see how we can internationalize the new app directory with the use of i18next, react-i18next and i18next-resources-to-backend.


npm install i18next react-i18next i18next-resources-to-backend

  1. Folder structure
  2. Language detection
  3. i18n instrumentation
  4. Language switcher
  5. Client side
  6. Bonus

1. Folder structure

Let's start by creating a new folder structure that uses the language as url parameter. A so called dynamic segment:

.
└── app
    └── [lng]
        ├── second-page
        |   └── page.js
        ├── layout.js
        └── page.js
Enter fullscreen mode Exit fullscreen mode

The app/[lng]/page.js file could look like this:

import Link from 'next/link'

export default function Page({ params: { lng } }) {
  return (
    <>
      <h1>Hi there!</h1>
      <Link href={`/${lng}/second-page`}>
        second page
      </Link>
    </>
  )
}
Enter fullscreen mode Exit fullscreen mode

And the app/[lng]/second-page/page.js file could look like this:

import Link from 'next/link'

export default function Page({ params: { lng } }) {
  return (
    <>
      <h1>Hi from second page!</h1>
      <Link href={`/${lng}`}>
        back
      </Link>
    </>
  )
}
Enter fullscreen mode Exit fullscreen mode

Last the app/[lng]/layout.js file could look like this:

import { dir } from 'i18next'

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

export async function generateStaticParams() {
  return languages.map((lng) => ({ lng }))
}

export default function RootLayout({
  children,
  params: {
    lng
  }
}) {
  return (
    <html lang={lng} dir={dir(lng)}>
      <head />
      <body>
        {children}
      </body>
    </html>
  )
}
Enter fullscreen mode Exit fullscreen mode

2. Language detection

Now navigating to http://localhost:3000/en or http://localhost:3000/de should show something, and also the links to the second page and back should work, but navigating to http://localhost:3000 will return a 404 error.


To fix that we'll create a Next.js middleware and refactor a bit of code:

Let's first create a new file app/i18n/settings.js:

export const fallbackLng = 'en'
export const languages = [fallbackLng, 'de']
Enter fullscreen mode Exit fullscreen mode

Then adapt the app/[lng]/layout.js file:

import { dir } from 'i18next'
import { languages } from '../i18n/settings'

export async function generateStaticParams() {
  return languages.map((lng) => ({ lng }))
}

export default function RootLayout({
  children,
  params: {
    lng
  }
}) {
  return (
    <html lang={lng} dir={dir(lng)}>
      <head />
      <body>
        {children}
      </body>
    </html>
  )
}
Enter fullscreen mode Exit fullscreen mode

And finally create a middleware.js file:


npm install accept-language

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

acceptLanguage.languages(languages)

export const config = {
  matcher: '/:lng*'
}

const cookieName = 'i18next'

export function middleware(req) {
  let lng
  if (req.cookies.has(cookieName)) lng = acceptLanguage.get(req.cookies.get(cookieName).value)
  if (!lng) lng = acceptLanguage.get(req.headers.get('Accept-Language'))
  if (!lng) lng = fallbackLng

  if (req.nextUrl.pathname === '/') {
    return NextResponse.redirect(new URL(`/${lng}`, req.url))
  }

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

  return NextResponse.next()
}
Enter fullscreen mode Exit fullscreen mode

middleware

Navigating to the root path / will now check if there's already a cookie with the last chosen language, as fallback it will check the Accept-Language header and the last fallback is the defined fallback language.


The detected language will be used to redirect to the appropriate page.

3. i18n instrumentation

Let's prepare i18next in the app/i18n/index.js file:


We're not using the i18next singleton here but create a new instance on each useTranslation call, because during compilation everything seems to be executed in parallel. Having a separate instance will keep the translations consistent.

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

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

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

In the app/i18n/settings.js file we'll add the i18next options:


export const fallbackLng = 'en'
export const languages = [fallbackLng, 'de']
export const defaultNS = 'translation'

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

Let's prepare some translation files:

.
└── app
    └── i18n
        └── locales
            ├── en
            |   ├── translation.json
            |   └── second-page.json
            └── de
                ├── translation.json
                └── second-page.json
Enter fullscreen mode Exit fullscreen mode

app/i18n/locales/en/translation.json:

{
  "title": "Hi there!",
  "to-second-page": "To second page"
}
Enter fullscreen mode Exit fullscreen mode

app/i18n/locales/de/translation.json:

{
  "title": "Hallo Leute!",
  "to-second-page": "Zur zweiten Seite"
}
Enter fullscreen mode Exit fullscreen mode

app/i18n/locales/en/second-page.json:

{
  "title": "Hi from second page!",
  "back-to-home": "Back to home"
}
Enter fullscreen mode Exit fullscreen mode

app/i18n/locales/de/second-page.json:

{
  "title": "Hallo von der zweiten Seite!",
  "back-to-home": "Zurück zur Hauptseite"
}
Enter fullscreen mode Exit fullscreen mode

Now we're ready to use that in our pages...


Server pages can by async this way we can await the useTranslation response.

app/[lng]/page.js:

import Link from 'next/link'
import { useTranslation } from '../i18n'

export default async function Page({ params: { lng } }) {
  const { t } = await useTranslation(lng)
  return (
    <>
      <h1>{t('title')}</h1>
      <Link href={`/${lng}/second-page`}>
        {t('to-second-page')}
      </Link>
    </>
  )
}
Enter fullscreen mode Exit fullscreen mode

app/[lng]/second-page/page.js:

import Link from 'next/link'
import { useTranslation } from '../../i18n'

export default async function Page({ params: { lng } }) {
  const { t } = await useTranslation(lng, 'second-page')
  return (
    <>
      <h1>{t('title')}</h1>
      <Link href={`/${lng}`}>
        {t('back-to-home')}
      </Link>
    </>
  )
}
Enter fullscreen mode Exit fullscreen mode

app_de_1

4. Language switcher

Now let's define a language switcher in a Footer component:

app/[lng]/components/Footer/index.js:

import Link from 'next/link'
import { Trans } from 'react-i18next/TransWithoutContext'
import { languages } from '../../../i18n/settings'
import { useTranslation } from '../../../i18n'

export const Footer = async ({ lng }) => {
  const { t } = await useTranslation(lng, 'footer')
  return (
    <footer style={{ marginTop: 50 }}>
      <Trans i18nKey="languageSwitcher" t={t}>
        Switch from <strong>{{lng}}</strong> to:{' '}
      </Trans>
      {languages.filter((l) => lng !== l).map((l, index) => {
        return (
          <span key={l}>
            {index > 0 && (' or ')}
            <Link href={`/${l}`}>
              {l}
            </Link>
          </span>
        )
      })}
    </footer>
  )
}
Enter fullscreen mode Exit fullscreen mode

You see we can also use the react-i18next Trans component.

A new namespace:

app/i18n/locales/en/footer.json:

{
  "languageSwitcher": "Switch from <1>{{lng}}</1> to: "
}
Enter fullscreen mode Exit fullscreen mode

app/i18n/locales/de/footer.json:

{
  "languageSwitcher": "Wechseln von <1>{{lng}}</1> nach: "
}
Enter fullscreen mode Exit fullscreen mode

And add that Footer component to the pages:

app/[lng]/page.js:

import Link from 'next/link'
import { useTranslation } from '../i18n'
import { Footer } from './components/Footer'

export default async function Page({ params: { lng } }) {
  const { t } = await useTranslation(lng)
  return (
    <>
      <h1>{t('title')}</h1>
      <Link href={`/${lng}/second-page`}>
        {t('to-second-page')}
      </Link>
      <Footer lng={lng}/>
    </>
  )
}
Enter fullscreen mode Exit fullscreen mode

app/[lng]/second-page/page.js:

import Link from 'next/link'
import { useTranslation } from '../../i18n'
import { Footer } from '../components/Footer'

export default async function Page({ params: { lng } }) {
  const { t } = await useTranslation(lng, 'second-page')
  return (
    <>
      <h1>{t('title')}</h1>
      <Link href={`/${lng}`}>
        {t('back-to-home')}
      </Link>
      <Footer lng={lng}/>
    </>
  )
}
Enter fullscreen mode Exit fullscreen mode

language switcher

🥳 Awesome, you've just created your first language switcher!

5. Client side

So far we've created server side pages only.


So how does client side pages look like?

Since client side react components can't async we need to do some adjustments.

Let's introduce the app/i18n/client.js file:

'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 } from './settings'

const runsOnServerSide = typeof window === 'undefined'

// 
i18next
  .use(initReactI18next)
  .use(LanguageDetector)
  .use(resourcesToBackend((language, namespace) => 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, ns, options) {
  const [cookies, setCookie] = useCookies(['i18next'])
  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('i18next', lng, { path: '/' })
    }, [lng, cookies.i18next])
  }
  return ret
}
Enter fullscreen mode Exit fullscreen mode

On client side the normal i18next singleton is ok. It will be initialized just once. And we can make use of the "normal" useTranslation hook. We just wrap it to have the possibility to pass in the language.

We also need to create 2 versions of the Footer component.

.
└── app
    └── [lng]
        └── components
            └── Footer
                ├── client.js
                ├── FooterBase.js
                └── index.js
Enter fullscreen mode Exit fullscreen mode

app/[lng]/components/Footer/FooterBase.js:

import Link from 'next/link'
import { Trans } from 'react-i18next/TransWithoutContext'
import { languages } from '../../../i18n/settings'

export const FooterBase = ({ t, lng }) => {
  return (
    <footer style={{ marginTop: 50 }}>
      <Trans i18nKey="languageSwitcher" t={t}>
        Switch from <strong>{{lng}}</strong> to:{' '}
      </Trans>
      {languages.filter((l) => lng !== l).map((l, index) => {
        return (
          <span key={l}>
            {index > 0 && (' or ')}
            <Link href={`/${l}`}>
              {l}
            </Link>
          </span>
        )
      })}
    </footer>
  )
}
Enter fullscreen mode Exit fullscreen mode

The server side part continuous to use the async version, app/[lng]/components/Footer/index.js:

import { useTranslation } from '../../../i18n'
import { FooterBase } from './FooterBase'

export const Footer = async ({ lng }) => {
  const { t } = await useTranslation(lng, 'footer')
  return <FooterBase t={t} lng={lng} />
}
Enter fullscreen mode Exit fullscreen mode

The client side part will use the new i18n/client version, app/[lng]/components/Footer/client.js:

'use client'

import { FooterBase } from './FooterBase'
import { useTranslation } from '../../../i18n/client'

export const Footer = ({ lng }) => {
  const { t } = useTranslation(lng, 'footer')
  return <FooterBase t={t} lng={lng} />
}
Enter fullscreen mode Exit fullscreen mode

A client side page could look like this - app/[lng]/client-page/page.js:

'use client'

import Link from 'next/link'
import { useTranslation } from '../../i18n/client'
import { Footer } from '../components/Footer/client'
import { useState } from 'react'

export default function Page({ params: { lng } }) {
  const { t } = useTranslation(lng, 'client-page')
  const [counter, setCounter] = useState(0)
  return (
    <>
      <h1>{t('title')}</h1>
      <p>{t('counter', { count: counter })}</p>
      <div>
        <button onClick={() => setCounter(Math.max(0, counter - 1))}>-</button>
        <button onClick={() => setCounter(Math.min(10, counter + 1))}>+</button>
      </div>
      <Link href={`/${lng}`}>
        <button type="button">
          {t('back-to-home')}
        </button>
      </Link>
      <Footer lng={lng} />
    </>
  )
}
Enter fullscreen mode Exit fullscreen mode

With some translation resources:

app/i18n/locales/en/client-page.json:

{
  "title": "Client page",
  "counter_one": "one selected",
  "counter_other": "{{count}} selected",
  "counter_zero": "none selected",
  "back-to-home": "Back to home"
}
Enter fullscreen mode Exit fullscreen mode

app/i18n/locales/de/client-page.json:

{
  "title": "Client Seite",
  "counter_one": "eines ausgewählt",
  "counter_other": "{{count}} ausgewählt",
  "counter_zero": "keines ausgewählt",
  "back-to-home": "Zurück zur Hauptseite"
}
Enter fullscreen mode Exit fullscreen mode

And a link in our initial page - app/[lng]/page.js:

import Link from 'next/link'
import { useTranslation } from '../i18n'
import { Footer } from './components/Footer'

export default async function Page({ params: { lng } }) {
  const { t } = await useTranslation(lng)
  return (
    <>
      <h1>{t('title')}</h1>
      <Link href={`/${lng}/second-page`}>
        {t('to-second-page')}
      </Link>
      <br />
      <Link href={`/${lng}/client-page`}>
        {t('to-client-page')}
      </Link>
      <Footer lng={lng}/>
    </>
  )
}
Enter fullscreen mode Exit fullscreen mode

...with translation resources:

app/i18n/locales/en/translation.json:

{
  "title": "Hi there!",
  "to-second-page": "To second page",
  "to-client-page": "To client page"
}
Enter fullscreen mode Exit fullscreen mode

app/i18n/locales/de/translation.json:

{
  "title": "Hallo Leute!",
  "to-second-page": "Zur zweiten Seite",
  "to-client-page": "Zur clientseitigen Seite"
}
Enter fullscreen mode Exit fullscreen mode

🎉🥳 Congratulations 🎊🎁

The result should look like this:

result

🧑‍💻 The complete code of an example app can be found here.

6. Bonus

transform the localization process

Connect to an awesome translation management system and manage your translations outside of your code.

Let's synchronize the translation files with locize.
This can be done on-demand or on the CI-Server or before deploying the app.

What to do to reach this step:

  1. in locize: signup at https://locize.app/register and login
  2. in locize: create a new project
  3. install the locize-cli (npm i locize-cli)
  4. in locize: add all your additional languages (this can also be done via API or with using the migrate command of the locize-cli)

Use the locize-cli

Use the locize download command to always download the published locize translations to your local repository (app/i18n/locales) before bundling your app. example

Alternatively, you can also use the locize sync command to synchronize your local repository (app/i18n/locales) with what is published on locize. example

🎉🥳 Congratulations 🎊🎁

I hope you’ve learned a few new things about i18n in the new app directory setup, Next.js, i18next, react-i18next, react-i18next, i18next-resources-to-backend and modern localization workflows.

So if you want to take your i18n topic to the next level, it's worth to try the localization management platform - locize.

The founders of locize are also the creators of i18next. So with using locize you directly support the future of i18next.

👍

Comments 32 total

  • rc
    rcDec 9, 2022

    hi there a error, when i try a not exit path. didnt go to "not-found"page.
    "Server Error
    Error: A required parameter (slug) was not provided as a string in generateStaticParams for /[lng]/[slug]

    This error happened while generating the page. Any console logs will be displayed in the terminal window."

  • Aung Myat Moe
    Aung Myat MoeDec 9, 2022

    You should use lang or locale in product ready products instead of lng. lng means what?

  • Christian Nascimento
    Christian NascimentoJan 7, 2023

    Nice post! Awesome! I follow the code but I had problems to implement a typescript version. Any ideia?

  • bejarano-tech
    bejarano-techJan 11, 2023

    Great job, awesome post.!!

    I have a issue that is because i want that the / path to be responded by the fallback language, example /enterprises to be responded by /en-US/enterprises. Do you know how i can accomplish that?

  • Lars Rye Jeppesen
    Lars Rye JeppesenJan 25, 2023

    Lots of typing issues when going to Typescript

  • Lars Rye Jeppesen
    Lars Rye JeppesenJan 25, 2023

    I have to admit, coming from Angular, this is such a bad experience in comparison.

    In Angular you just use a system-provided prop called i18n and you use it in component templates like<h1 i18n="id_for_my_translation">Translate-me</h1>

    No special imports needed, no function calls, no contexts, no having to make components async etc etc.

    • Tim Goyer
      Tim GoyerFeb 2, 2023

      To be fair, using the react-i18n library in a vanilla React app is just as easy as you mention in Angular, and possibly easier.

      This article is about getting it to work in a SSR-first NextJS v13 app. v13 is a brand new paradigm in the React space and still in beta. You are comparing apples to oranges.

    • Ali Bayat Mokhtari
      Ali Bayat MokhtariMay 20, 2023

      Good for you and Angular

  • Aulia Rahman
    Aulia RahmanFeb 9, 2023

    What if I want to make the root path as default language like this:

    • http://localhost:3000 ==> will be en without showing the /en prefix because it's the default language.
    • and if I go to http://localhost:3000/fr it will translate to fr

    How can I achieve this?

    • mattmana
      mattmanaFeb 13, 2023

      great post! i need same as you, i think its a custom route in middleware.js, did you resolve it?

  • omor shahriar
    omor shahriarFeb 20, 2023

    Great post, just one query. Say, the site user is in some deeply nested route. And I want the functionality to change that route to other language, not routing them to main index like it's doing here. How could that be implemented?

  • Paul GH Cheung
    Paul GH CheungApr 5, 2023

    Do you have an example of how to use dynamic routes? I had an error when I created the dynamic page folder 'Server Error
    Error: A required parameter (name) was not provided as a string in generateStaticParams for /[lng]/[name]

    This error happened while generating the page. Any console logs will be displayed in the terminal window.' thanks

  • Vugar Yusifli
    Vugar YusifliMay 16, 2023

    Hi. Thanks, good tutorial. It would be cool to create a new post with backend (i18next-http-backend).

  • marchitecht
    marchitechtJul 10, 2023

    Why do you use both Clint and server side components for i18n in app directory? Isn’t it enough to have one of them?

    • Adriano Raiano
      Adriano RaianoJul 13, 2023

      if you don‘t use client pages, you need only the server side option

  • Veronica Quispe
    Veronica QuispeJul 19, 2023

    An excellent guide for those who are learning a little more about it, I implemented it in Typescript version and it looks great, I only have a problem, I can not access the images in the public folder as I did, any idea why this happens?

    • Adriano Raiano
      Adriano RaianoJul 20, 2023

      You may need to adapt the middleware

      • Veronica Quispe
        Veronica QuispeJul 25, 2023

        Thanks for your answer, indeed, I had to adapt the middleware, now I was able to implement it without any problem. Thank you!

  • Alexandre Abreu
    Alexandre AbreuAug 23, 2023

    Awesome eg. with new App Router approach.

    Much appreciated! 🎉

  • Azuma Shingo
    Azuma ShingoAug 25, 2023

    Has anyone else done this with @next/font?

    It have stopped loading the fonts in my environment without any errors. The generated class name is assigned a font family with a name like '__Noto_Sans_JP_Fallback_d2549e', which seems to fail to provide a font. Because it says Fallback.

    I'm wondering if perhaps it is the redirect settings that are not serving the fonts properly on the server side...

    • Azuma Shingo
      Azuma ShingoAug 28, 2023

      Sorry... I deleted .next and node_modules and restarted, then it works fine.

  • viktor_k
    viktor_kOct 6, 2023

    how i can get nested translate object for example : { data: { title: 'test', data2: { title: 'test'} } }

Add comment