DEV Community

Andrew Jones
Andrew Jones

Posted on

 

The Ultimate Guide to internationalization (i18n) in Next.js 13

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)

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.

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

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:

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).*)",],
};
Enter fullscreen mode Exit fullscreen mode

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

export default function Home({
  params,
}: {
  params: { lang: string; country: string };
}) {
  return <h1>{JSON.stringify(params)}</h1>;
}
Enter fullscreen mode Exit fullscreen mode

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, and see something like this:

Homepage with hello world parameters

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.)

// 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) {

}
Enter fullscreen mode Exit fullscreen mode

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.

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
      )
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

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:

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
      )
    );
  }
Enter fullscreen mode Exit fullscreen mode

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 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:

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;
};
Enter fullscreen mode Exit fullscreen mode

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:

  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
        )
      );
    }
  }
Enter fullscreen mode Exit fullscreen mode

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
Select "Show sensors" to open the menu, and add one of our supported locales:

Locale override in Chrome dev tools

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:

// 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],
    };
  }
};
Enter fullscreen mode Exit fullscreen mode

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:

(dictionaries/en-CA.json)
{
    "welcome": {
      "helloWorld": "Hello World, eh?",
      "happyYear": "Happy {{ year }}!"
    }
  }
Enter fullscreen mode Exit fullscreen mode
(dictionaries/en-US.json)
{
  "welcome": {
    "helloWorld": "Hello World!",
    "happyYear": "Happy {{ year }}!"
  }
}
Enter fullscreen mode Exit fullscreen mode
(dictionaries/fr-CA.json)
{
  "welcome": {
    "helloWorld": "Salut le Monde!",
    "happyYear": "Bonne annΓ©e {{ year }}!"
  }
}
Enter fullscreen mode Exit fullscreen mode

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

// 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;
  };
};
Enter fullscreen mode Exit fullscreen mode

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:

// 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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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 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:

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

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

export default async function Home({
.......
Enter fullscreen mode Exit fullscreen mode

Run yarn build to confirm everything is working, and the project should build! βœ…

Site build output

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:

// /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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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

A big thanks to everyone who has contributed ideas on i18n in this GitHub issue

Next.js 13 App directory docs
Next.js 13 guide to localization

Top comments (5)

Collapse
 
sithi5 profile image
Sithis • Edited

Thank you for this tutorial, it's working fine for me :).

Instead of returning the key in case there is no local word we can also back-up to the default dictionary:

const defaultDictionary =
        locale !== defaultLocale
            ? await import(`./dictionaries/${defaultLocale}.json`)
            : '';

...

if (!translation && locale !== defaultLocale) {
            // Back-up to default locale dictionary
            translation = key
                .split('.')
                .reduce((obj, key) => obj && obj[key], defaultDictionary);
            if (!translation) {
                return key;
            }
        }
Enter fullscreen mode Exit fullscreen mode
Collapse
 
spock123 profile image
Lars Rye Jeppesen

This is what I have been looking for. Thank you so much.

Collapse
 
rhmnaulia profile image
Aulia Rahman • Edited

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?

Collapse
 
thedevmian profile image
thedevmian • Edited

Yes, you can do this. If you don't want to write your own middleware you can use library next-intl and install beta version. Check the link here. It has similar configuration like previous version of build-in support for i18n for pages dir, so you can set it like

locales: ['en', 'fr'],
defaultLocale: 'en',

and defaultLocale will be just / without /en.
It works for me ;)

Collapse
 
596050 profile image
Shrouded Shrew

Tried to create a pull request for your repository. When using 'use client', I needed this:

function useDelayedRender<T>(asyncFun: () => Promise<T>, deps = []) {
    const [output, setOutput] = useState<T>()

    useEffect(() => {
        ;(async function () {
            try {
                setOutput(await asyncFun())
            } catch (e) {
                console.error(e)
            }
        })()
    }, deps)
    return output === undefined ? null : output
}


export default function Home({
    params,
}: {
    params: { lang: string; country: string }
}) {
    return useDelayedRender(async () => {
        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>
        )
    })
Enter fullscreen mode Exit fullscreen mode

16 Libraries You Should Know as a React Developer

>> Check out this classic DEV post <<