Next.js internationalization (i18n): Going international with next-intl

39 mins read
next.js translate with crowdin

While English is known to be the lingua franca of the web, when you look at the the top languages by number of native speakers, the result looks fairly more diverse:

  1. Chinese (1.3 billion native speakers)
  2. Spanish (485 million native speakers)
  3. English (373 million native speakers)
  4. Arabic (362 million native speakers)
  5. Hindi (344 million native speakers)

With language being the foundation of communication, it’s clear that if you want to make your web content available to an international audience, you can enhance the user experience by supporting more than a single language. Internationalization, often abbreviated to i18n, furthermore allows your business to expand globally and enter new markets.

In this article, we will explore the process of adapting a Next.js site that was initially designed for a single language & market to be able to cater to an international audience. We will achieve this by implementing internationalization using next-intl, a library specifically created for Next.js that offers all the relevant pieces for an internationalized web experience.

To practically explore this transition, we will discuss the Next.js internationalization of an e-commerce storefront for a bicycle manufacturer based in the UK that is planning to sell its products to Switzerland.

Step 1: First look at the app

The Next.js app that we’ll be working with uses the App Router and is already set up with a home page and a product detail page. Basic knowledge of the Next.js App Router is assumed for this article, so depending on your experience, you might want to catch up on this architecture before continuing.

Let’s have a look at the current app to identify all the aspects that we need to address as part of the internationalization.

Home page: This page features a large hero section at the top, a list of popular bicycles, and testimonials below.

home-before.webp

Product detail page: This page provides details on a specific bicycle.

next.js internationalization example

The way the app is set up, the product data as well as the testimonials are retrieved from a backend, using a REST API.

Step 2: Multilingual or multi-regional?

When building internationalized web experiences, we’re typically thinking in two paradigms:

  • Multilingual: These websites provide content in more than one language.
  • Multi-regional: These websites explicitly target users in different countries.

While apps might fall into one or the other category, some can be both multilingual and multi-regional.

In the case of our app, the business is located in the UK and is now planning to sell its products in Switzerland. The primary language spoken in Switzerland is German, so we need to localize the content to this language. However, Switzerland has not one, but a total of four national languages: German, French, Italian, and a small fraction of the population has Romansh as the first language.

Furthermore, apart from localizing text content, we need to adapt to market-specifics of the country, like supporting a new currency: The Swiss Franc.

Due to this, our app qualifies as both multilingual and multi-regional.

After glancing over the existing app, we can identify the following aspects that we need to address as part of the internationalization:

  1. Labels: Currently, all app labels are hardcoded in the app. We need to replace them with dynamic labels that change based on the current language.
  2. Backend data: The product information from the database needs to be available in both English and German. Additionally, we would like to display testimonials based on the user’s language.
  3. Currency: We should support selling products in British pounds in the UK as well as Swiss Francs in Switzerland.
  4. Formatting: Number, date and time formatting should consider the language and region of the user.
  5. Country selector: While we can take a guess about the country and language preference of the user based on request headers, we should allow users to explicitly pick their preference.

That’s quite a bit, so let’s dive right in!

Step 3: Planning the URL structure

We describe languages, along with the formatting preferences of a user, with the term locale. Locales can optionally contain regional information.

Examples:

  • en-gb: English, as spoken in Great Britain
  • de-ch: German, as spoken in Switzerland

In our case, regional information is mandatory. Not only as a language preference but also for the app to use the local currency of the market that the store will be selling to.

Let’s look into the options that we have to incorporate the locale as well as the market into the URLs of the app.

Option 1: Market-centric URLs

By either using market-specific domains (e.g. domain.co.uk) or pathname prefixes (e.g. /uk), we can derive both the language as well as the currency from the URL:

// Will use the en-uk locale as well as the GBP currency
domain.co.uk

// Will use the de-ch locale as well as the CHF currency
domain.ch

Note that individual markets might have non-similar regulations, therefore especially in e-commerce this option can be relevant.

Pro: This approach offers flexibility for market-specific customization.

Con: We assume a single language & currency per market and country-specific domains are subject to availability (subdomains can be another option).

Option 2: Locale-centric URLs

Another option is to move the locale to the URL, typically as a pathname prefix:

// The locale is encoded in the URL
domain.com/en-gb
domain.com/de-ch
domain.com/fr-ch

With the locale being handled in the URL, we still have options for the currency selection:

  1. Allow the user to select a preferred currency and save it e.g. in a cookie.
  2. Derive the currency from the regional part of the locale (i.e. gb from en-gb).

Note that the regional part of a locale is typically a country code, but can be any sequence of two alphabetic characters. You can use this to combine countries with non-national languages—e.g. en-ch, describing the English language with Switzerland as the region.

Pro: This approach supports multiple languages per country.

Con: Market-specific customization is harder with this structure.

Option 3: Combining markets and locales in URLs

The approaches listed above can be combined to provide a maximum of flexibility, allowing for market-specific customization as well as language and currency selection per market:

// Market: United Kingdom
// Language: English, as spoken in Great Britain
domain.co.uk/en-gb

// Market: Switzerland
// Language: German, as spoken in Switzerland
domain.ch/de-ch

// Market: Switzerland
// Language: French, as spoken in Switzerland
domain.ch/fr-ch

Optionally, you can handle the primary language of a country without a locale prefix and use it only for secondary languages (e.g. domain.ch and domain.ch/fr).


For our example store in this article, we’ll go with option 2 from above, using locale-centric URLs (domain.com/en-gb). This provides us with sufficient flexibility to handle multiple languages per country. As we’re currently not planning on supporting multiple currencies per country, we’ll derive the country from the regional part of the locale.

Note that next-intl uses locale-centric URLs by default (e.g. /en), with an option to support market-specific URLs via separate domains. Market-specific pathname prefixes (e.g. /uk) are currently planned.

Step 4: Setting up next-intl

next-intl will provide the following pieces to enable internationalization of the app:

  1. Internationalized routing for Next.js
  2. Locale-specific text labels, referred to as messages
  3. Locale-specific date, time and number formatting

But first, let’s have a a look at how the app is initially structured:

└── src
    └── app
        ├── layout.tsx
        ├── page.tsx
        └── products
            └── [slug]
                └── page.tsx

After following the getting started guide of next-intl, our files will be organized like this:

├── src
│   ├── app
│   │   └── [locale]
│   │       ├── layout.tsx
│   │       ├── page.tsx
│   │       └── products
│   │           └── [slug]
│   │               └── page.tsx
│   ├── i18n.ts
│   └── middleware.ts
└── messages
    └── en-gb.json

The most obvious difference is that all layout and page modules have been moved within the [locale] folder. This dynamic segment will help us to make all pages within the folder locale-aware.

Additionally, the following files were added:

  • messages/en-gb.json: This file will contain all our labels for the en-gb locale.
  • i18n.ts: This module is used to provide i18n configuration like locale-specific messages to React Server Components.
  • middleware.ts: The next-intl middleware will introduce a redirect for / to forward to the best-matching locale (e.g. /en-gb) and will set a cookie to remember the user preference.

After this setup is complete, the home page will now be available at /en-gb, matching the [locale] segment.

As the next step, we’ll adapt the app to incorporate the locale in all relevant places.

Step 5: Extracting hardcoded labels

Once the general setup is in place, we’ll begin extracting the previously hardcoded labels into dynamic messages.

Here’s an example of extracting the static labels from the hero section of the home page:

export default function Hero() {
  return (
    <div>
      <h1>Two wheels, endless possibilities.</h1>
      <p>Unleash your ride with our premier bicycle collection.</p>
    </div>
  );
}

After the extraction, our component now looks like this:

import {useTranslations} from 'next-intl';

export default function Hero() {
  const t = useTranslations('Hero');

  return (
    <div>
      <h1>{t('title')}</h1>
      <p>{t('description')}</p>
    </div>
  );
}

The labels are now moved to en-gb.json:

{
  "Hero": {
    "title": "Two wheels, endless possibilities.",
    "description": "Unleash your ride with our premier bicycle collection."
  }
}

The useTranslations hook returns a function called t that can be invoked with the key of a message. As a result, it returns a locale-specific translation string from the messages.

const t = useTranslations('Hero');

// "Two wheels, endless possibilities."
t('title'); 

By convention, we use component names for creating namespaces in our messages. These namespaces hold all messages for a given component, making it easy to reason about where a given message is used. This is optional however, so if you have another preference, feel free to use that.

We’ll continue this process with the other labels in the app that were previously hardcoded, progressing one component at a time.

Tip: The ESLint rule react/jsx-no-literals can be helpful with spotting all hardcoded labels in component markup. Once a static label is spotted, the extraction can be streamlined by using a VSCode integration for next-intl.

Step 6: Internationalizing backend data

Once we have the static labels extracted on the home page, we’ll look into incorporating the locale and country into components that use backend data.

Firstly, featured products should be adapted to use the country of the user to return a price in the local currency:

next.js translate

Secondly, testimonials should consider the language of the user:

backend-testimonials.webp

As planned, we’re going to use the locale to retrieve both the language preference of the user as well as regional information about the market (i.e. the country).

To forward this information to the backend, all endpoints will now accept a locale parameter:

GET /api/testimonials?locale=en-gb
GET /api/products?locale=en-gb
GET /api/products/roadster-classic?locale=en-gb

The backend can now extract the country from the locale search param:

function getCountry(locale: string) {
  return new Intl.Locale(locale).region;
}

getCountry('en-gb'); // "GB"

… and return localized data based on this information:

{
  // ...
  "price": {
    "value": 1199,
    "currency": "GBP"
  }
}

On the frontend side, we can now pass the locale that we receive as a dynamic segment to all relevant API calls:

import {getProducts, getTestimonials} from '@/api';

export default async function IndexPage(props: {params: {locale: string}}) {
  const products = await getProducts(props.params);
  const testimonials = await getTestimonials(props.params);

  return (
    // ..
  );
}

Step 7: Formatting currencies

In the initial implementation, currencies were simply rendered by assembling the price value with the currency:

// "1199 GBP"
{product.price.value} {produce.price.currency}

While this naive implementation works to some degree, the result doesn’t look very appealing.

Let’s see if we can improve this by switching to the useFormatter hook from next-intl:

import {useFormatter} from 'next-intl';

export default function ProductListItem({product}) {
  const format = useFormatter();

  return (
    <Card>
      {/* ... */}
      <CardDescription>
        {format.number(product.price.value, {
          style: 'currency',
          currency: product.price.currency
        })}
      </CardDescription>
    </Card>
  );
}

Here’s the result:

price-formatted.webp

Much better!

The currency is now turned into a symbol and thousands as well as decimal separators are added.

It’s worth mentioning that many aspects of number formatting can vary between locales:

  1. Digit grouping
  2. Currency sign position
  3. Decimal & thousands separators

next-intl solves this by relying on the Intl.NumberFormat API that is available in modern JavaScript runtimes and has knowledge about all major locales (i.e. all of the ISO 639 language codes).

Step 8: Formatting dates

Similarly to how we’ve improved formatting of currencies while incorporating the locale, we can apply the same treatment to date formatting.

The initial implementation uses the following formatting code to turn a Date object into a human readable string:

function formatDate(value: Date) {
  const date = String(value.getDate()).padStart(2, '0');
  const month = String(value.getMonth() + 1).padStart(2, '0');
  const year = value.getFullYear();

  return `${date}/${month}/${year}`;
}

return (
  // ...

  // "Delivery by 30/02/2024"
  <p>Delivery by {formatDate(product.deliveryBy)}</p>
);

Let’s replace this with a call to useFormatter too:

const format = useFormatter();

return (
  // ...

  // "30 Feb 2024"
  <p>{format.dateTime(product.deliveryDate, {dateStyle: 'medium'})}</p>
);

That’s better. Note that next-intl provides a few built-in date formats like 'medium' that can be referenced by name. For more specific use cases, all options of the Intl.DateTimeFormat API can be used.

But what about the leading “Delivery by” part?

To combine text labels with dynamic parts, next-intl uses ICU syntax that allows to embed dynamic variables into messages, optionally with additional formatting.

Let’s refactor our implementation again and extract this message into our messages JSON file:

"ProductPageContent": {
  "delivery": "Delivery by {deliveryDate, date, medium}"
},

… and call it like this:

t('delivery', {deliveryDate: product.deliveryBy})

Here’s the result:

date-formatted.webp

Great. By referencing the deliveryDate in our message, we furthermore provide translators the flexibility to decide at which place the date should be embedded within the text label.

Step 9: Next.js i18n routing

At this point, the content of our app already looks decent and is internationalized. However, there’s one major piece still missing: All page links need to be adapted to point to localized alternatives.

import Link from 'next/link';

// ❌ Points to a 404 since our pages 
// are prefixed like `/en-gb/about`
<Link href="/about">{t('about')}</Link>;

To fix our links, we’ll use the next-intl navigation APIs which act as a locale-aware drop-in replacement for the APIs known from Next.js.

We can create the navigation APIs by calling the createSharedPathnamesNavigation factory function in a central module like src/navigation.ts:

import {createSharedPathnamesNavigation} from 'next-intl/navigation';

export const locales = ['en-gb'];
 
export const {Link, redirect, usePathname, useRouter} =
  createSharedPathnamesNavigation({locales});

The locales argument for createSharedPathnamesNavigation is identical to the one that we need to pass to the middleware. To make sure it is in sync, we’ll import it into src/middleware.ts:

import createMiddleware from 'next-intl/middleware';
import {locales} from './navigation';

export default createMiddleware({
  defaultLocale: 'en-gb',
  locales
});

// ...

The customized Link component can now be used to replace all instances of next/link:

import {Link} from '@/navigation';

// âś… Automatically considers the locale and
// creates a valid link like `/en-gb/about`
<Link href="/about">{t('about')}</Link>;

With this, all page links work again and will automatically incorporate the locale.

Tip: In this example we use a module path alias, making it easy to import from src/navigation.ts across the code base.

Step 10: Localizing to Swiss German

Now, with all the refactoring work out of the way, we can finally localize the app to de-ch!

As a first step, we’ll add the new locale to the locales array in src/navigation.ts:

export const locales = ['en-gb', 'de-ch'];

// ...

The middleware matcher in src/middleware.ts should be updated accordingly too:

// ...

export const config = {
  // Match only internationalized pathnames
  matcher: ['/', '/(en-gb|de-ch)/:path*']
};

Hint: A Next.js middleware matcher needs to be statically analyzable, therefore we can’t use the locales array to generate the regex.

And finally, we’ll add messages/de-ch.json with updated labels from our translators:

{
  "Hero": {
    "allProducts": "Alle Fahrräder erkunden",
    "description": "Entfessle dein Fahrerlebnis mit unserer erstklassigen Fahrradsammlung.",
    "title": "Zwei Räder, unendliche Möglichkeiten."
  },
  // ...
}

With this step completed, we’re now able to render the localized home page by navigating to /de-ch:

home-de-ch.webp

Also the product detail page renders correctly with localized content for de-ch:

pdp-de-ch.webp

On this page, we notice that the backend has returned a localized price that uses the CHF currency (i.e. Swiss Francs). Our refactoring with useFormatter has really paid off here as the formatting of both the product price as well as the delivery date has automatically been adapted based on the new locale.

Step 11: Translated routes in Next.js

If you look carefully at the product detail page, you might notice that there’s one piece missing to be localized: the pathname of the page (/de-ch/products/roadster-sport). The URL is part of the user interface too and has SEO relevance, therefore we should localize the products segment too.

We can achieve this by switching the factory function in src/navigation.ts from createSharedPathnamesNavigation to createLocalizedPathnamesNavigation and providing adapted pathnames by locale:

import {createLocalizedPathnamesNavigation} from 'next-intl/navigation';
import {Pathnames} from 'next-intl/navigation';

export const locales = ['en-gb', 'de-ch'] as const;

export const pathnames = {
  '/': '/',
  '/products': {
    'en-gb': '/products',
    'de-ch': '/produkte'
  }
  '/products/[slug]': {
    'en-gb': '/products/[slug]',
    'de-ch': '/produkte/[slug]'
  },
  '/about': {
    'en-gb': '/about',
    'de-ch': '/uber-uns'
  }
} satisfies Pathnames<typeof locales>;

export const {Link, redirect, usePathname, useRouter} =
  createLocalizedPathnamesNavigation({locales, pathnames});

Just like the locales array, the pathnames mapping is also relevant for the middleware since it adds internal rewrites for localized pathnames. Due to this, we will pass the pathnames in src/middleware.ts as well:

import createMiddleware from 'next-intl/middleware';
import {locales, pathnames} from './navigation';

export default createMiddleware({
  defaultLocale: 'en-gb',
  locales,
  pathnames
});

// ...

After this change, links to pages like /products will immediately be localized:

next.js locale

For the product detail page at /products/[slug] a minor change is necessary though, since it uses dynamic params. Luckily, TypeScript will notify us about this:

// ❌ This link will no longer work since 
// we need to pass the `slug` separately.
<Link href={`/products/${product.slug}`}>
  <ProductListItem product={product} />
</Link>

TypeScript asks us to provide the pathname and params separately, so they can be compiled into a locale-specific pathname template:

Next js localization

With this step completed, the app now renders localized links for the product detail page too:

localized-pathname-pdp.webp

Note that the next-intl middleware will include the link response header for all localized content pages, informing search engines about locale-specific variations of a page so that the best-matching one can be prioritized in search results:

Link: <http://localhost:3000/en-gb/products/roadster-sport>; rel="alternate"; hreflang="en-gb",
      <http://localhost:3000/de-ch/produkte/roadster-sport>; rel="alternate"; hreflang="de-ch"

Step 12: Creating a locale switcher

At this point, the app is fully functional for two locales that are available at the pathname prefixes /en-gb and /de-ch. What happens when the user visits the root pathname / though?

By default, the next-intl middleware will provide a redirect to a localized version of our app when the root pathname is requested. If the user has already visited the app, the previous locale is saved in the NEXT_LOCALE cookie and will be re-applied here. For new visitors, the accept-language header is used for negotiating the best-matching locale.

In our case, the locale represents not only the language preference of the user though, but the market that the app serves. Therefore the accept-language header might produce incorrect redirects in case someone who is located in Switzerland will visit the app and has English configured as the browser language. We could instead use geographical information from the request IP to implement a redirect, but also this can produce inaccurate results. Better yet, we should explicitly let the user select the correct market.

To enable this, we’re going to add a country flag to the header, which allows users to switch between markets:

next.js get locale

We’re going to split the locale switcher markup and the interactive select element into two components, enabling us to add the 'use client' marker only to the select element.

Let’s start with the outer component that orchestrates all markup for the control:

import {useLocale, useTranslations} from 'next-intl';
import LocaleSwitcherSelect from './LocaleSwitcherSelect';
import LocaleIcon from './LocaleIcon';

export default function LocaleSwitcher() {
  const t = useTranslations('LocaleSwitcher');
  const locale = useLocale();

  return (
    <label>
      <p className="sr-only">{t('label')}</p>
      <LocaleIcon locale={locale} />
      <LocaleSwitcherSelect defaultValue={locale}>
        <option value="en-gb">{t('en-gb')}</option>
        <option value="de-ch">{t('de-ch')}</option>
      </LocaleSwitcherSelect>
    </label>
  );
}

We’re using a new hook from next-intl here: useLocale. This hook returns the current locale and can be used by us to render a matching flag icon.

Apart from that, we can use the useTranslations hook to prepare labels for the individual select options:

next.js change locale

Note that we translate the language in the option labels to the target locale, allowing users to find a language they can understand, regardless of the currently active app locale.

LocaleSwitcherSelect is implemented as a Client Component and uses two hooks that we’ve created based on the localized pathnames factory function in src/navigation.ts:

'use client';

import {usePathname, useRouter} from '@/navigation';
import {useParams} from 'next/navigation';

export default function LocaleSwitcherSelect(props: React.ComponentProps<'select'>) {
  // This router automatically considers the `locale` 
  // behind the scenes and allows to switch it.
  const router = useRouter();

  // E.g. `{slug: 'roadster-sport'}` on the product detail page
  const params = useParams();

  // Since we're using localized pathnames, this returns a
  // a pathname template like `/products/[slug]` that can
  // be compiled with a specific locale by the router.
  const pathname = usePathname();

  function onChange(event: React.ChangeEvent<HTMLSelectElement>) {
    const locale = event.target.value;
    router.push(
      {
        pathname,
        // TypeScript validates that only known `params` are used in 
        // combination with a given `pathname`. Since the two will always
        // match for the current route, we can skip runtime checks.
        params: params as any
      },
      {locale}
    );
  }

  return <select {...props} onChange={onChange} />;
}

Note that since this Client Component doesn’t access any translations via useTranslations but the enclosing component passes them via children, we can handle translations purely in Server Components and reduce the client bundle size of the app.

That’s it!

And with that, we have our fully internationalized e-commerce storefront set up:

next.js link locale

If you’d like to see the finished app in action, you can explore it here: Bicycle Store.

To recap, this is the process that we’ve followed:

  1. Firstly, we’ve internationalized the app. This means that we’ve extracted all hardcoded labels into dynamic translations, added support for multiple currencies in the backend and improved our number & date formatting to be locale-specific. On top, we’ve added internationalized routing and a locale switcher.
  2. Secondly, we’ve localized the app for the Swiss market. The implementation part of this consisted of adding translated messages and pathnames in the app, as well as returning the correct currency in the backend. We didn’t have to change anything in regard to number and date formatting since this is automatically handled by next-intl.

Note that the first step was a one-time effort, now enabling us to localize the app to further languages and markets.

Switzerland is a good example for internationalization since it’s a multilingual country. With the internationalized setup of our app in place, we now have a great foundation to add further locales like fr-ch and it-ch. We can also add non-national languages for the Swiss market with locales like en-ch if this is a use case that we want to support.

Tip: In this article we’ve assumed that translators have already provided us with translations for the de-ch locale based on our source en-gb. The Crowdin translation management system can help engineering teams to collaborate with translators, ensuring a seamless continual integration of translations in the development cycle.

Resources:

Localize your product with Crowdin

Automate content updates, boost team collaboration, and reach new markets faster.
Jan Amann