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:
- Chinese (1.3 billion native speakers)
- Spanish (485 million native speakers)
- English (373 million native speakers)
- Arabic (362 million native speakers)
- 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.
Product detail page: This page provides details on a specific bicycle.
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:
- 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.
- 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.
- Currency: We should support selling products in British pounds in the UK as well as Swiss Francs in Switzerland.
- Formatting: Number, date and time formatting should consider the language and region of the user.
- 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 Britainde-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:
- Allow the user to select a preferred currency and save it e.g. in a cookie.
- Derive the currency from the regional part of the locale (i.e.
gb
fromen-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:
- Internationalized routing for Next.js
- Locale-specific text labels, referred to as messages
- 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 theen-gb
locale.i18n.ts
: This module is used to provide i18n configuration like locale-specific messages to React Server Components.middleware.ts
: Thenext-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:
Secondly, testimonials should consider the language of the user:
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:
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:
- Digit grouping
- Currency sign position
- 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:
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
:
Also the product detail page renders correctly with localized content for de-ch
:
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:
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:
With this step completed, the app now renders localized links for the product detail page too:
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:
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:
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:
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:
- 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.
- 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:
next-intl
: Internationalization (i18n) for Next.js- Next.js App Router
- Image sources: unsplash.com, vecrorportal.com.