The Ultimate Guide to internationalization (i18n) in Next.js 13
Tags: nextjs, webdev, javascript, tutorial
Published: 2023-01-16 19:19:56

## Introduction

Internationalization can be a complex challenge for developers to get right, since it ties in with infrastructure. This post will teach you how to nail internationalized routing and content translations in your Next.js 13 website or web application.

I work in the e-commerce space, with brands spanning countries and continents - almost all of them require different website content per language or region. According to Vercel, the company behind Next.js: 

> 72% of consumers are more likely to stay on your site if it's been translated and 55% of consumers said they only buy from e-commerce sites in their native language.
([quote source](https://nextjs.org/blog/next-10#internationalized-routing))

So it's incredibly important for your sites to have i18n support, to maximize users and conversion rates. 

Understanding that, it may surprise you to find out that the new Next.js 13 `app` directory _drops_ the i18n support the `pages` directory has had built-in since 2020.

Note: this tutorial assumes basic familiarity with the `app` directory in Next.js 13. More info linked at the bottom. **Additionally**, if you get lost, [check out the finished code here.](https://github.com/aej11a/i18n-next13)

## How will I localize my Next.js 13 project without built-in support?

The built-in internationalized routing that launched in Next.js 10 in Oct. 2020 had many limitations. For example, the routing structure was opinionated, requiring a single URL subpath or different TLD, like `example.com/en-US, example.com/en-CA` or `example.us, example.ca`. URLs with two subpaths was _not_ supported, so `example.com/en/us` would be impossible.

A year later, in Oct 2021, Next.js released with support for **custom middleware**, which allows developers to use our own business logic for routing. With this system, internationalized routing doesn't need to be built-in to the platform, because it's now entirely in our control how Next will route users. Since we control routing, and the file structure, we can now set up more flexible URL structures.

In Next.js 13, while the old `pages` directory maintains existing support for i18n routing, it's intentionally removed from `app` directory routing to put the power into developers' hands.

## Goals

In this tutorial, we'll do something we couldn't do before Next.js 13 - set up international routing and translations using _two subpaths_ like `example.com/en/us`. We will include static generation, locale detection, and handling a default locale which shouldn't require the subpath.

Our website will have 3 supported locales corresponding to the following regional dialects: 

- `en-US` which should be our default and have no URL subpath,
- `en-CA` for Canadian English,
- `fr-CA` for Canadian French

## Set up the File Structure

Start by creating a Next.js app with the `--experimental-app` flag (as of Jan. 2023). We'll use TypeScript too.

Copy all of the content inside `app/` into a new folder, `app/[lang]/[country]`. Also create a new file at the project-root level called `middleware.ts`. Your folder structure should look like this:

![File structure](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ifnpnvd8deum16kr1g88.png)

In `middleware.tsx`, add an import of the Next.js Request type and add an empty default function and a matcher to ignore non-content URLs:

```ts
export function middleware(request: NextRequest) {
  
}

export const config = {
// do not localize next.js paths
  matcher: ["/((?!api|_next/static|_next/image|assets|favicon.ico|sw.js).*)",],
};
```

In `page.tsx` replace the default function with the following, which will display the parameters the page is receiving:

```tsx
export default function Home({
  params,
}: {
  params: { lang: string; country: string };
}) {
  return <h1>{JSON.stringify(params)}</h1>;
}
```

Now you should be able to run your project with `npm run dev` and then navigate to any URL with two parameters, such as [`localhost:3000/hello/world`](http://localhost:3000/hello/world), and see something like this:

![Homepage with hello world parameters](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/38ocofvh2u36ntj1vw4s.png)

The URL parameters are working great, but this isn't a valid locale!

## Handle Locale Validation, Redirects, and Rewrites with Middleware

We'll use middleware to validate that the user is accessing a valid locale, at the correct URLs, and display the default locale when a user is at the site root with no subpath. 

The middleware runs on every request to the site that matches the regex we added above. So, we can use it to redirect the user, or use a "rewrite" which allows us to show one page's content even when the user is on a different URL.

First, let's specify our default locale, list of allowed locales, and a convenience function to help us process the locale parts. (**Note:** you wouldn't need the convenience function if you only use one subpath, like `example.com/en-US`, but we'll use the more complex case in this guide.)

```ts
// middleware.ts

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

const defaultLocale = "en-US";
let locales = ["en-US", "en-CA", "fr-CA"];

type PathnameLocale = {
  pathname: string;
  locale?: never;
};
type ISOLocale = {
  pathname?: never;
  locale: string;
};

type LocaleSource = PathnameLocale | ISOLocale;

const getLocalePartsFrom = ({ pathname, locale }: LocaleSource) => {
  if (locale) {
    const localeParts = locale.toLowerCase().split("-");
    return {
      lang: localeParts[0],
      country: localeParts[1],
    };
  } else {
    const pathnameParts = pathname!.toLowerCase().split("/");
    return {
      lang: pathnameParts[1],
      country: pathnameParts[2],
    };
  }
};

export function middleware(request: NextRequest) {
  
}
```

Next, let's figure out how to send users from `/en/us`, our default locale, back to `/`. First we'll check if the current requested URL path matches the default locale, and if it does then we'll remove the subpath.

```ts
export function middleware(request: NextRequest) {
  const pathname = request.nextUrl.pathname;

  const defaultLocaleParts = getLocalePartsFrom({ locale: defaultLocale });
  const currentPathnameParts = getLocalePartsFrom({ pathname });

  // Check if the default locale is in the pathname
  if (
    currentPathnameParts.lang === defaultLocaleParts.lang &&
    currentPathnameParts.country === defaultLocaleParts.country
  ) {
    // we want to REMOVE the default locale from the pathname,
    // and later use a rewrite so that Next will still match
    // the correct code file as if there was a locale in the pathname
    return NextResponse.redirect(
      new URL(
        pathname.replace(
          `/${defaultLocaleParts.lang}/${defaultLocaleParts.country}`,
          pathname.startsWith("/") ? "/" : ""
        ),
        request.url
      )
    );
  }
}
``` 
With this change, try navigating to `localhost:3000/en/us` and you should be redirected to `localhost:3000`. If you try navigating to `localhost:3000/en/ca`, you should not be redirected.

Now that we're redirecting users from `localhost:3000/en/us` and invalid locales to `localhost:3000`, we need Next.js to display `/` _as if it matched the filepath `/app/[lang]/[country]`_ to prevent an incorrect 404, since we don't have a root `app/page.tsx` file. We also **should serve a 404 if the pathname doesn't match any valid locale**. We can kill two birds with one stone using the following loop and **rewrite**:

```ts
const pathnameIsMissingValidLocale = locales.every((locale) => {
    const localeParts = getLocalePartsFrom({ locale });
    return !pathname.startsWith(`/${localeParts.lang}/${localeParts.country}`);
  });

  if (pathnameIsMissingValidLocale) {
    // rewrite it so next.js will render `/` as if it was `/en/us` 
    return NextResponse.rewrite(
      new URL(
        `/${defaultLocaleParts.lang}/${defaultLocaleParts.country}${pathname}`,
        request.url
      )
    );
  }
```

**How does this work?** At this point, if you try to access `localhost:3000/hello/world`, since it doesn't match any locale in our array, Next.js will treat it as `localhost:3000/en/us/hello/world`, a file path which doesn't exist, and give you a 404 page.

With that, we've achieved our routing requirements! 🎉

## Automatic locale detection

Now that we have a great routing setup, we can optimize the user experience by **automatically redirecting users to the correct route based on their location and language preferences**. This way they shouldn't need to manually use a language picker on your site (though it may still be a feature you want to make available).

First, run `npm i accept-language-parser` and `npm i @types/accept-language-parser --save-dev`. [accept-language-parser](https://www.npmjs.com/package/accept-language-parser) is a small library that will help us process the request headers to find the best language for a user.

Next, add this import to the top of your `middleware.ts` file, and add the `findBestMatchingLocale` function under the locales declarations:
```ts
import langParser from "accept-language-parser";

const defaultLocale = "en-US";
const locales = ["en-US", "en-CA", "fr-CA"];

const findBestMatchingLocale = (acceptLangHeader: string) => {
  // parse the locales acceptable in the header, and sort them by priority (q)
  const parsedLangs = langParser.parse(acceptLangHeader);

  // find the first locale that matches a locale in our list
  for (let i = 0; i < parsedLangs.length; i++) {
    const parsedLang = parsedLangs[i];
    // attempt to match both the language and the country
    const matchedLocale = locales.find((locale) => {
      const localeParts = getLocalePartsFrom({ locale });
      return (
        parsedLang.code === localeParts.lang &&
        parsedLang.region === localeParts.country
      );
    });
    if (matchedLocale) {
      return matchedLocale;
    }
    // if we didn't find a match for both language and country, try just the language
    else {
      const matchedLanguage = locales.find((locale) => {
        const localeParts = getLocalePartsFrom({ locale });
        return parsedLang.code === localeParts.lang;
      });
      if (matchedLanguage) {
        return matchedLanguage;
      }
    }
  }
  // if we didn't find a match, return the default locale
  return defaultLocale;
};
```
This function will find the best matching locale by iterating through the priority-sorted locales from the request and comparing them to the available locales on our site. First it will try to find an available locale matching the requested language _and_ country, and if none is found then it will fall back to just matching the language, and finally fall back to the default locale.

In the last step, we rewrote all URLs without a matching locale subpath to use the en-US locale. In this step, we'll redirect the user if there's a matching locale other than the default. If the default is the best match, we'll use the same rewrite as before.

Update the final `if`-statement in the middleware file to the following:
```ts
  if (pathnameIsMissingValidLocale) {
    // rewrite it so next.js will render `/` as if it was `/en/us`

    const matchedLocale = findBestMatchingLocale(
      request.headers.get("Accept-Language") || defaultLocale
    );

    if (matchedLocale !== defaultLocale) {
      const matchedLocaleParts = getLocalePartsFrom({ locale: matchedLocale });
      return NextResponse.redirect(
        new URL(
          `/${matchedLocaleParts.lang}/${matchedLocaleParts.country}${pathname}`,
          request.url
        )
      );
    } else {
      return NextResponse.rewrite(
        new URL(
          `/${defaultLocaleParts.lang}/${defaultLocaleParts.country}${pathname}`,
          request.url
        )
      );
    }
  }
```

How do we test this? Chrome allows us to simulate locales from different requests.

In Chrome dev tools, press `cmd + shift + P` and type in `sensors`
![Chrome dev tools command window](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/l8jrpzvzfzczznpqbnqd.png)
Select "Show sensors" to open the menu, and add one of our supported locales: 

![Locale override in Chrome dev tools](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/j7qosjmdkqvtf5vy451c.png)

Now if you attempt to navigate to the homepage without a sub-domain, you should be redirected to `/fr/ca`. Even if you enter `fr-FR` (French standard, rather than Canadian French), you'll still be redirectedto the French Canadian subpath instead of the English subpath. Voila! 🎊

## Content Translation

For content translation, we'll use the official Next.js 13 guide to localization as a foundation, but add variable interpolation.

Start by moving all of our locale code out of `middleware.ts` into a new file also at the project root, `i18n.ts`:
```ts
// i18n.ts

export const defaultLocale = "en-US";
export const locales = ["en-US", "en-CA", "fr-CA"] as const;
export type ValidLocale = typeof locales[number];

type PathnameLocale = {
  pathname: string;
  locale?: never;
};
type ISOLocale = {
  pathname?: never;
  locale: string;
};

type LocaleSource = PathnameLocale | ISOLocale;

export const getLocalePartsFrom = ({ pathname, locale }: LocaleSource) => {
  if (locale) {
    const localeParts = locale.toLowerCase().split("-");
    return {
      lang: localeParts[0],
      country: localeParts[1],
    };
  } else {
    const pathnameParts = pathname!.toLowerCase().split("/");
    return {
      lang: pathnameParts[1],
      country: pathnameParts[2],
    };
  }
};
```
I also added the `as const` directive to add a `ValidLocale` enum based on the locales array. Make sure to update the imports in your `middleware.tsx` to reflect these changes.

Next, add a `dictionaries` folder in your project root with three JSON files, one for each locale:

```json
(dictionaries/en-CA.json)
{
    "welcome": {
      "helloWorld": "Hello World, eh?",
      "happyYear": "Happy {{ year }}!"
    }
  }
  ```
```json
(dictionaries/en-US.json)
{
  "welcome": {
    "helloWorld": "Hello World!",
    "happyYear": "Happy {{ year }}!"
  }
}
```
```json
(dictionaries/fr-CA.json)
{
  "welcome": {
    "helloWorld": "Salut le Monde!",
    "happyYear": "Bonne année {{ year }}!"
  }
}
```
Next we'll add a translation function into `i18n.ts` which can be called by server components to import the dictionary and get the requested translation. This function will also handle variable interpolation similar to `i18next`

```ts
// i18n.ts
const dictionaries: Record<ValidLocale, any> = {
  "en-US": () =>
    import("dictionaries/en-US.json").then((module) => module.default),
  "en-CA": () =>
    import("dictionaries/en-CA.json").then((module) => module.default),
  "fr-CA": () =>
    import("dictionaries/fr-CA.json").then((module) => module.default),
} as const;

export const getTranslator = async (locale: ValidLocale) => {
  const dictionary = await dictionaries[locale]();
  return (key: string, params?: { [key: string]: string | number }) => {
    let translation = key
      .split(".")
      .reduce((obj, key) => obj && obj[key], dictionary);
    if (!translation) {
      return key;
    }
    if (params && Object.entries(params).length) {
      Object.entries(params).forEach(([key, value]) => {
        translation = translation!.replace(`{{ ${key} }}`, String(value));
      });
    }
    return translation;
  };
};
```

We can now use the translations in our `page.tsx` file. Since the function to import the translation files is async, we'll need to make the `Home()` function async as well:
```tsx
// page.tsx

import { getLocalePartsFrom, locales, ValidLocale, getTranslator } from "@/i18n";

export default async function Home({
  params,
}: {
  params: { lang: string; country: string };
}) {
  const translate = await getTranslator(
    `${params.lang}-${params.country.toUpperCase()}` as ValidLocale // our middleware ensures this is valid
  );
  return (
    <div>
      <h1>{translate("welcome.helloWorld")}</h1>
      <h2>
        {translate("welcome.happyYear", {
          year: new Date().getFullYear(),
        })}
      </h2>
    </div>
  );
}
```

The `translate` function is created based off of the current locale subpath the user is on, and then content can be translated by adding keys to our JSON files. If there are any keys missing translations, the key itself will be rendered. And what's best - since this is all server-side code, we don't need to ship a huge JSON translations object to the browser.

## How do I translate content in client components?

We're able to make our Home() function above asynchronous, and use `getTranslator`, _because_ Home() is a server component. 

We can't make client components asynchronous, so this is what I would recommend:

1. Maximize usage of server components.
2. Don't forget that you can render a server component as a child of a client component (see [here](https://beta.nextjs.org/docs/rendering/server-and-client-components#importing-server-components-into-client-components) for info). This means you can often break out static text elements into a separate server component. 
3. For text that you unavoidably need to translate in a client component, you can execute the translations on the server component and **pass the text into the client component as a prop**.

If #3 concerns you, thinking about cases of prop-drilling, consider point #2 again. It should be rare to have deeply-nested child components that don't have a server-component parent or grandparent.

## Use generateStaticParams to statically generate the localized pages and prevent build errors

If you try to build the project after the localization steps above, you should get an error, because Next will attempt to import a dictionary for the generic case of any string locales. 

To fix this, **we need to tell Next to only statically generate pages for our valid locales**. 

Add this function in `page.tsx`:

```ts
import { ....... } from "@/i18n";

export async function generateStaticParams() {
  return locales.map((locale) => getLocalePartsFrom({ locale }));
}

export default async function Home({
.......
```

Run `yarn build` to confirm everything is working, and the project should build! ✅


![Site build output](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/8x0x1ljg0jf0pjlmc9h7.png)

Then run `yarn start` and navigate to one of the localized subpaths to see the translations.

So far, I've had to include the same snippet on every page I create. For example:

```tsx
// /app/[lang]/[country]/examplePage/page.tsx
import { getTranslator } from "@/i18n";
import { getLocalePartsFrom, locales, ValidLocale } from "@/i18n";

export async function generateStaticParams() {
  return locales.map((locale) => getLocalePartsFrom({ locale }));
}

export default async function ExamplePage({
  params,
}: {
  params: { lang: string; country: string };
}) {
  const translate = await getTranslator(
    `${params.lang}-${params.country.toUpperCase()}` as ValidLocale // our middleware ensures this is valid
  );
  return (
    <div>
      <h1>Example page: {translate("welcome.helloWorld")}</h1>
    </div>
  );
}
```

## Taking it further

Ideas to consider:
1. You could test out different URL structures, such as single-subpath locales or different domains, by customizing the routing rules in the middleware.
2. You may want to customize the translation rules for missing keys. For example, instead of returning the key, return the default locale's text for that key.
3. You may want to have some pages unlocalized, which you can add in `/app`
4. You could build a frontend language picker component.
5. Make the `dictionaries` object dynamic based on the allowed locales

## Conclusion!

- The `app` directory in Next.js 13 doesn't include the built-in localization system of the `pages` directory.
- Instead, it gives us more flexibility via middleware and powerful server-side rendering.
- We used middleware logic to redirect users and rewrite pages based on the requested URL and each user-requests's Accept-Language header.
- We used the power of server components to translate content and reviewed methods of translating client component content
- We used generateStaticParams to correctly build the project

Thanks for reading, hope this helps! If you find anything wrong, please let me know in the comments or on [Twitter](https://twitter.com/ajones55555)

_A big thanks to everyone who has contributed ideas on i18n in [this GitHub issue](https://github.com/vercel/next.js/issues/41980)_

[Next.js 13 App directory docs](https://beta.nextjs.org/docs/getting-started)
[Next.js 13 guide to localization](https://beta.nextjs.org/docs/guides/internationalization)