Software localization
Next.js 13/14 App Router Localization with next-intl
Next.js by Vercel has become the React framework. Next.js’ downloads have surpassed those of create-react-app and the official React documentation recommends the framework for spinning up new React projects. Little wonder: Next.js solves previously headache-inducing problems like routing and server-side rendering with elegance, making modern React development fun again.
The introduction of the App Router in Next.js 13 added the flexibility and convenience of React Server Components (RSC)—it also complicated Next.js internationalization (i18n). Thankfully, the next-intl library by Jan Amann simplifies Next.js 13/14 App Router i18n, offering robust i18n solutions, including translation management, localized formatting, and excellent support for server components.
🔗 Learn more » Internationalization (i18n) and localization (l10n) allow us to make our apps available in different languages and regions, often for more profit. If you’re new to i18n and l10n, check out our guide to internationalization.
🔗 Resource » For localizing a Next.js app using the Pages Router and the i18next library, refer to our step-by-step guide to Next.js internationalization.
🔗 Resource » If you want to use the Format.js/react-intl library with the Pages Router instead, check out Next.js Localization with Format.JS/react-intl.
Our demo app
We’ll build a minimalistic mock weather app to help us work through our i18n problems, creatively named Next.js Weather.
Let’s build this app and localize it step-by-step.
Package versions used
We use the following NPM packages in this guide.
Library | Version used | Description |
typescript | 5.4 | Our programming language of choice. |
next | 14.2 | The full-stack React framework. |
react | 18.2 | A somewhat popular UI library. |
next-intl | 3.11 | Our i18n library. |
rtl-detect | 1.1 | For detecting right-to-left languages. |
tailwindcss | 3.4 | For styling; largely optional for our purposes. |
clsx | 2.1 | For dynamic CSS class assignment; largely optional for our purposes. |
🔗 Resource » Get the complete code for our demo from GitHub.
Building the demo
With that out of the way, let’s get building. We’ll spin up a fresh Next.js app from the command line:
npx create-next-app@latest
Code language: Bash (bash)
The usual setup Q&A follows. Here’s what I entered:
- Would you like to use TypeScript? Yes
- Would you like to use ESLint? Yes
- Would you like to use Tailwind CSS? Yes
- Would you like to use
src/
directory? No - Would you like to use App Router? (recommended) Yes
- Would you like to customize the default import alias (@/*)? No
After our new app has spun up, let’s override the default Next.js boilerplate:
// app/layout.tsx
import Header from "@/app/_components/Header";
import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "Next.js Weather",
description:
"A weather app built with Next.js and next-intl",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body className="...">
<Header />
{children}
</body>
</html>
);
}
Code language: JavaScript (javascript)
🗒️ Note » We’re omitting styles here for brevity (unless they directly pertain to i18n). You can get complete code listings from our GitHub repo (including styles).
The simple Header
in our RootLayout
is a navbar that tops all our pages.
// app/_components/Header.tsx
import Link from "next/link";
export default function Header() {
return (
<header className="...">
<nav>
<ul className="...">
<li>
<Link href="/" className="...">
Next.js Weather
</Link>
</li>
<li>
<Link href="/week" className="...">
This week
</Link>
</li>
<li>
<Link href="/about" className="...">
About
</Link>
</li>
</ul>
</nav>
</header>
);
}
Code language: JavaScript (javascript)
Our home page lists some mock hard-coded data for “today’s weather”.
// app/page.tsx
export default function Home() {
return (
<main>
<h1 className="...">
Today's weather
</h1>
<h2 className="...">Monday April 15 2024</h2>
<section>
<div className="...">
<p className="...">☀️</p>
<p className="...">Sunny</p>
<p className="...">22°C</p>
</div>
</section>
</main>
);
}
Code language: JavaScript (javascript)
Weekly weather
To simulate an async server component, let’s build a page at the /week
route that loads some mock JSON data: daily weather forecasts for the week. Here’s what our JSON looks like:
// app/_data/week.json
{
"weeklyWeather": [
{
"dateTime": "2024-04-15T12:00:00Z",
"condition": "sunny",
"conditionIcon": "☀️",
"temperature": {
"celsius": 22,
"fahrenheit": 71.6
}
},
{
"dateTime": "2024-04-16T12:00:00Z",
"condition": "partlyCloudy",
"conditionIcon": "⛅️",
"temperature": {
"celsius": 20,
"fahrenheit": 68
}
},
// ...
]
}
Code language: JSON / JSON with Comments (json)
🔗 Resource » Get the entire JSON file from GitHub.
Of course, we’ll need to declare some TypeScript types for it:
// types.ts
export interface WeeklyWeatherRoot {
weeklyWeather: WeeklyWeather[];
}
export interface WeeklyWeather {
dateTime: string;
condition: string;
conditionIcon: string;
temperature: Temperature;
}
export interface Temperature {
celsius: number;
fahrenheit: number;
}
Code language: PHP (php)
We can now create a page component that pulls and displays this data.
// app/week/page.tsx
import type { WeeklyWeatherRoot } from "@/types";
import { promises as fs } from "fs";
export default async function Week() {
const fileContents = await fs.readFile(
`${process.cwd()}/app/_data/week.json`,
"utf-8",
);
const { weeklyWeather } = JSON.parse(
fileContents,
) as WeeklyWeatherRoot;
return (
<main>
<h1 className="...">
This week's weather
</h1>
<div className="...">
{weeklyWeather.map((day) => (
<section key={day.dateTime} className="...">
<h2 className="...">
{new Date(day.dateTime).toString()}
</h2>
<div>
<div className="...">
<p className="...">
{day.conditionIcon}
</p>
<p className="...">
{day.condition}
</p>
<p className="...">
{day.temperature.celsius}°C
</p>
</div>
</div>
</section>
))}
</div>
</main>
);
}
Code language: JavaScript (javascript)
The formatting could be better here; we’ll improve it as we localize the page. Speaking of which, let’s get to localization!
🔗 Resource » Get the code for our entire starter app (before localization) from GitHub.
How do I localize my Next.js app with next-intl?
Let’s look at the recipe for localizing a Next.js app with next-intl:
1. Install and set up next-intl.
2. Use t()
for component string translations.
3. Configure localized routing.
4. Build a language switcher.
5. Localize Client Components.
6. Localize Async components.
7. Enable static rendering.
8. Apply localized formatters for dates/numbers.
9. Localize page metadata.
We’ll work through these steps one at a time.
How do I install and configure next-intl?
First things first, let’s install the next-intl NPM package from the command line:
npm install next-intl
Code language: Bash (bash)
Adding the locale route segment
Next, we’ll move all our pages under a [locale]
dynamic route segment. This will allow us to turn a given /foo
route to localized routes like /en-us/foo
(English USA) and /ar-eg/foo
(Arabic Egypt).
🗒️ Note » A locale indicates a language and a region, represented by a code like fr-ca
for French as used in Canada. While “language” and “locale” are sometimes used interchangeably, it’s important to know the difference. Here’s a handy list of locale codes.
This is the relevant project hierarchy before the [locale]
segment:
.
└── app
├── about
│ └── page.tsx
├── layout.tsx
├── page.tsx
└── week
└── page.tsx
Code language: plaintext (plaintext)
Let’s create a new subdirectory called [locale]
under app
(or src/app
if you’re using a src
directory) and place our routed pages there. Here is our hierarchy after the [locale]
route segment:
.
└── app
└── [locale]
├── about
│ └── page.tsx
├── layout.tsx
├── page.tsx
└── week
└── page.tsx
Code language: plaintext (plaintext)
🗒️ Note » Be sure to update your module import
s if need be.
Adding translation message files
We’ll need to pull our hard-coded strings out of our components and into translation files, one for each locale we want to support. Let’s add a new directory called locales
under our project root with two new JSON files under it:
.
└── locales
├── ar-eg.json
└── en-us.json
Code language: plaintext (plaintext)
🗒️ Note » In this tutorial, we’re supporting en-us
(English USA) and ar-eg
(Arabic Egypt). Feel free to add any locales you want here.
Let’s start small and translate our app’s title in the Header
component. We need to add a key/value pair for each translation message, making sure we use the same keys across locale files:
// locales/en-us.json
{
"Header": {
"appTitle": "Next.js Weather"
}
}
Code language: JSON / JSON with Comments (json)
// locales/ar-eg.json
{
"Header": {
"appTitle": "تقص نكست چى إس",
}
}
Code language: JSON / JSON with Comments (json)
🗒️ Note » We’ve namespaced our appTitle
translation under Header
to associate it with the Header component. This message structure is recommended by next-intl but is not necessary. Read the Structuring messages section of the next-intl documentation to learn more.
We’re on the right track, but there’s still a bit to do before we can use our new translations. First up, we need to integrate next-intl with our Next.js app.
Setting up configuration files
We need to create a few small setup files to get next-intl working smoothly. This includes adding a plugin to our next.config.mjs
file.
// next.config.mjs
+ import createNextIntlPlugin from "next-intl/plugin";
+ const withNextIntl = createNextIntlPlugin();
/** @type {import('next').NextConfig} */
const nextConfig = {};
- export default nextConfig;
+ export default withNextIntl(nextConfig);
Code language: Diff (diff)
We’ll often refer to our app’s supported locales, so it’s wise to configure them in a single, central file. Let’s create a new file called i18n.config.ts
at the root of our project to house our config.
// i18n.config.ts
export const locales = ["en-us", "ar-eg"] as const;
export type Locale = (typeof locales)[number];
Code language: JavaScript (javascript)
next-intl uses a special i18n.ts
configuration file to load translations—not to be confused with the previous i18n.config.ts
. By default, this file must be in the project route and called i18n.ts
, although this is configurable.
// i18n.ts
import { getRequestConfig } from "next-intl/server";
import { notFound } from "next/navigation";
import { locales, type Locale } from "./i18n.config";
// Load the translation file for the active locale
// on each request and make it available to our
// pages, components, etc.
export default getRequestConfig(async ({ locale }) => {
if (!locales.includes(locale as Locale)) {
return notFound();
}
return {
messages: (await import(`./locales/${locale}.json`))
.default,
};
});
Code language: JavaScript (javascript)
One last boilerplate is wiring up a Next.js middleware that next-intl provides. Let’s create a middleware.ts
file at the root of our project and set it up with some starter config.
// middleware.ts
import createMiddleware from "next-intl/middleware";
import { locales } from "./i18n.config";
export default createMiddleware({
// Use this locale when we can't match
// another with our user's preferred locales
// and when no locale is explicitly set.
defaultLocale: "en-us",
// List all supported locales (en-us, ar-eg).
locales,
// Automatic locale detection is enabled by
// default. We're disabling it to keep things
// simple for now. We'll enable it later when
// we cover locale detection.
localeDetection: false,
});
// Our middleware only applies to routes that
// match the following:
export const config = {
matcher: [
// Match all pathnames except for
// - … if they start with `/api`, `/_next` or `/_vercel`
// - … the ones containing a dot (e.g. `favicon.ico`)
"/((?!api|_next|_vercel|.*\\..*).*)",
],
};
Code language: JavaScript (javascript)
🗒️ Note » I used a different middleware route matcher than the next-intl recommended "/(en-us|ar-eg)/:path*"
. This approach avoids duplicating the locale list and works well in general. Just take care to add a specific matcher for any localized routes containing a dot (.
).
🗒️ Note » If you’re using other middleware, check out the next-intl documentation on Composing other middlewares.
A note on the NEXT_LOCALE cookie
Despite disabling locale detection, next-intl uses a NEXT_LOCALE
cookie to store the current locale, ensuring users consistently see their preferred language on subsequent visits. This is useful for maintaining user preferences but can lead to confusion during development. Consider deleting the NEXT_LOCALE
cookie via your browser’s dev tools when you test initial route access.
A basic test
Phew! That was a good amount of configuration. It’s well worth the effort, however, considering how much custom code and headaches next-intl saves us. Let’s put the library to the test and use the translation files we added above. Here they are again to refresh our memory:
// locales/en-us.json
{
"Header": {
"appTitle": "Next.js Weather"
}
}
Code language: JSON / JSON with Comments (json)
// locales/ar-eg.json
{
"Header": {
"appTitle": "تقص نكست چى إس",
}
}
Code language: JSON / JSON with Comments (json)
The simplest way to add these translations to our components is via next-intl’s useTranslations()
hook:
// app/_components/Header.tsx
+ import { useTranslations } from "next-intl";
import Link from "next/link";
export default function Header() {
+ // t will be scoped to "Header"...
+ const t = useTranslations("Header");
return (
<header className="...">
<nav>
<ul className="...">
<li>
<Link
href="/"
className="..."
>
- Next.js Weather
+ {/* ...because we're scoped to "Header", this
+ returns "Header.appTitle" from the active
+ locale's translation file. */}
+ {t("appTitle")}
</Link>
</li>
{/* ... */}
</ul>
</nav>
</header>
);
}
Code language: Diff (diff)
When we visit /en-us
, our app should look exactly as it did before. If we visit /ar-eg
, however, we should see our app title in Arabic.
Accessing the locale param
next-intl ensures that our routes are always localized under a /[locale]
route param. We can access this param as usual in our layouts and pages. Let’s use the param to make our <html lang>
attribute reflect the active locale.
// app/[locale]/layout.tsx
import Header from "@/app/_components/Header";
import "@/app/globals.css";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Next.js Weather",
description:
"A weather app built with Next.js and next-intl",
};
+ // Rename for clarity
- export default function RootLayout({
+ export default function LocaleLayout({
children,
+ params: { locale },
}: Readonly<{
children: React.ReactNode;
+ params: { locale: string };
}>) {
return (
- <html lang="en">
+ <html lang={locale}>
<body className="...">
<Header />
{children}
</body>
</html>
);
}
Code language: Diff (diff)
When we run our app now and visit /
, we’ll be redirected to /en-us
. If we open our browser dev tools, we should see the <html lang>
element reflecting the active locale in the route.
If we visit /ar-eg
we should see our <html lang>
update accordingly.
How do I translate basic text?
We covered this when we configured and set up next-intl. Let’s go over it one more time, however. Our Header
component needs its navigation links localized anyway.
// locales/en-us.json
// next-intl incentivizes structuring our translation
// keys so that they’re namespaced by component or page.
{
"Header": {
"appTitle": "Next.js Weather",
+ "navLinks": {
+ "week": "This week",
+ "about": "About"
+ }
}
}
Code language: Diff (diff)
// locales/ar-eg.json
{
"Header": {
"appTitle": "Next.js Weather",
+ "navLinks": {
+ "week": "هذا الأسبوع",
+ "about": "نبذة عنا"
+ }
}
}
Code language: Diff (diff)
The useTranslations
hook allows to pull translations for the active locale using its returned t()
function.
// app/_components/Header.tsx
+ // We added this before; higlighting for clarity.
+ import { useTranslations } from "next-intl";
import Link from "next/link";
export default function Header() {
+ // We added this before; higlighting for clarity.
+ const t = useTranslations("Header");
return (
<header className="...">
<nav>
<ul className="...">
<li>
<Link href="/" className="...">
{t("appTitle")}
</Link>
</li>
<li>
<Link href="/week" className="...">
- This week
+ {/* Note that we can refine into our
+ translation objects with `.` */}
+ {t("navLinks.week")}
</Link>
</li>
<li>
<Link href="/about" className="...">
- About
+ {t("navLinks.about")}
</Link>
</li>
</ul>
</nav>
</header>
);
}
Code language: Diff (diff)
There, now the navbar in our header is completely localized.
🗒️ Note » We can access all translations in a file by calling useTranslations()
with no params. If we did so in the above example, we would have to prefix the keys given t()
with "Header."
e.g. t("Header.navLinks.week")
.
How do I configure localized routing?
next-intl’s middleware defaults to managing localized routing with a prefixed-based strategy, where routes are prefixed with the active locale, like /en-us/about
.
🗒️ Note » next-intl provides a domain-based routing strategy that is beyond the scope of this article.
Configuring the locale prefix
Our default setting uses a forced locale prefix, meaning routes like /about
automatically redirect to /en-us/about
. This makes localized routing consistent, but we can adjust it. By changing the setting to as-needed
, the default locale won’t have a prefix, but all others will.
// middleware.ts
import createMiddleware from "next-intl/middleware";
import { locales } from "./i18n.config";
export default createMiddleware({
defaultLocale: "en-us",
locales,
localeDetection: false,
+ localePrefix: "as-needed",
});
export const config = {
matcher: [
"/((?!api|_next|_vercel|.*\\..*).*)",
],
};
Code language: Diff (diff)
With the as-needed
setting, visiting /
or /week
directly shows content in the default English locale without the /en-us
prefix. To access content in Arabic, such as the weekly weather, we must explicitly use the ar-eg
prefix e.g. /ar-eg/week
.
For this tutorial, we’ll revert the prefix setting to the forced "always"
mode:
// middleware.ts
import createMiddleware from "next-intl/middleware";
import { locales } from "./i18n.config";
export default createMiddleware({
defaultLocale: "en-us",
locales,
localeDetection: false,
- localePrefix: "as-needed",
+ // `"always"` is the default; we can
+ // omit the `localePrefix` option
+ // entirely and get the same result.
+ localePrefix: "always"
});
export const config = {
matcher: [
"/((?!api|_next|_vercel|.*\\..*).*)",
],
};
Code language: Diff (diff)
🗒️ Note » We can also set localePrefix: "never"
which disables prefixes entirely. This option relies on a cookie for determining the locale. See the Never using a prefix section of the next-intl docs for more information.
🗒️ Note » For SEO or usability, some consider localizing their pathnames, such as translating /ar-eg/about
to /ar-eg/نبذة-عنا
. For details on how to set this up and ensure proper navigation, check out the Localizing pathnames section in the next-intl documentation.
Alternate links
next-intl automatically generates alternate URLs, which are crucial for SEO as they inform search engines about available page translations. These appear in the link
header in HTTP response headers.
🔗 Resource » You can disable these alternate links, or customize them using your own middleware logic. The Alternate links section of the next-intl docs can guide you here.
How do I use localized links?
Our Next.js Link
components don’t automatically include locale prefixes like /ar-eg/week
for Arabic pages—they link directly to /week
. Although next-intl middleware corrects this by redirecting based on the active locale, links copied directly from our pages and shared lose their locale context. For consistent navigation and better SEO, it’s best to include locale prefixes in URLs explicitly.
Luckily, next-intl makes this easy by providing a drop-in replacement to Next.js’ Link
. We can expose it from our i18n.config.ts
file and use it in our app’s components.
// i18n.config.ts
+ import { createSharedPathnamesNavigation } from "next-intl/navigation";
export const locales = ["en-us", "ar-eg"] as const;
export type Locale = (typeof locales)[number];
+ export const { Link } =
+ createSharedPathnamesNavigation({ locales });
Code language: Diff (diff)
All it takes to use the next-intl-provided Link
is changing our module imports in our components.
// app/_components/Header.tsx
import { useTranslations } from "next-intl";
- import Link from "next/link";
+ import { Link } from "@/i18n.config";
export default function Header() {
const t = useTranslations("Header");
// ...
}
Code language: Diff (diff)
That’s it! Now when we’re on an Arabic page, our Link
s will explicitly point to the Arabic version of our pages.
Of course, when we’re on an English page, our Link
s explicitly point to the /en-us/foo
versions of our pages.
🗒️ Note » If you’re using localized pathnames that vary by locale (like /en-us/about
vs. /ar-eg/نبذة-عنا
), switch to using the createLocalizedPathnamesNavigation
function for accurate navigation. For more details, check the next-intl documentation on Localized pathnames.
How do I build a language switcher?
It’s useful to allow users to manually select their locale (even though we’ll attempt to automatically detect the best match from browser settings a bit later). In this section, we’ll create a Client Component with a <select>
dropdown as a locale switcher.
We’ll use next-intl’s programmatic navigation functions in our locale switcher. Let’s start by exposing these functions from our i18n.config.ts
file.
// i18n.config.ts
import { createSharedPathnamesNavigation } from "next-intl/navigation";
export const locales = ["en-us", "ar-eg"] as const;
export type Locale = (typeof locales)[number];
- export const { Link } =
+ export const { Link, usePathname, useRouter } =
createSharedPathnamesNavigation({ locales });
Code language: Diff (diff)
While we’re in i18n.config.ts
let’s write a little map containing human-friendly names for our locales; we’ll use this in our <select>
dropdown.
// i18n.config.ts
import { createSharedPathnamesNavigation } from "next-intl/navigation";
export const locales = ["en-us", "ar-eg"] as const;
export type Locale = (typeof locales)[number];
+ export const localeNames: Record<Locale, string> = {
+ "en-us": "English",
+ "ar-eg": "العربية (Arabic)",
+ };
export const { Link, usePathname, useRouter } =
createSharedPathnamesNavigation({ locales });
Code language: Diff (diff)
Alright, we’re ready to write our LocaleSwitcher
.
// app/_components/LocaleSwitcher.tsx
"use client";
import {
localeNames,
locales,
usePathname,
useRouter,
type Locale,
} from "@/i18n.config";
export default function LocaleSwitcher({
locale,
}: {
locale: Locale;
}) {
// `pathname` will contain the current
// route without the locale e.g. `/about`...
const pathname = usePathname();
const router = useRouter();
const changeLocale = (
event: React.ChangeEvent<HTMLSelectElement>,
) => {
const newLocale = event.target.value as Locale;
// ...if the user chose Arabic ("ar-eg"),
// router.replace() will prefix the pathname
// with this `newLocale`, effectively changing
// languages by navigating to `/ar-eg/about`.
router.replace(pathname, { locale: newLocale });
};
return (
<div>
<select
value={locale}
onChange={changeLocale}
className="..."
>
{locales.map((loc) => (
<option key={loc} value={loc}>
{localeNames[loc]}
</option>
))}
</select>
</div>
);
}
Code language: JavaScript (javascript)
🔗 Resource » Check out next-intl’s navigation options on the official docs.
Let’s add our new LocaleSwitcher
to the Header
component.
// app/_components/Header.tsx
- import { Link } from "@/i18n.config";
+ import { Link, type Locale } from "@/i18n.config";
- import { useTranslations } from "next-intl";
+ import { useLocale, useTranslations } from "next-intl";
+ import LocaleSwitcher from "./LocaleSwitcher";
export default function Header() {
const t = useTranslations("Header");
+ // Retrieves the active locale.
+ const locale = useLocale() as Locale;
return (
<header className="...">
<nav>
{/* ... */}
</nav>
+ <LocaleSwitcher locale={locale} />
</header>
);
}
Code language: Diff (diff)
We can’t call next-intl’s useLocale()
hook directly from a Client Component, so we’re passing the locale as a prop from the Header
component. We’ll cover Client Components in greater detail a bit later.
🗒️ Note » Remember that NEXT_LOCALE
cookie? It’s still being set by next-intl, updating to reflect the user’s selected locale. This ensures if a user selects Arabic (ar-eg
) from the dropdown, they will be automatically directed to the Arabic version of the site on future visits.
How do I automatically detect the user’s locale?
Modern browsers allow us to select a list of languages we prefer to see our web pages displayed in.
These languages are included in the Accept-Language
header sent with every HTTP request. next-intl utilizes this to automatically detect the visitor’s locale, a feature we previously disabled to simplify our reasoning about localized locales and navigation. Let’s re-enable it now.
// middleware.ts
import createMiddleware from "next-intl/middleware";
import { locales } from "./i18n.config";
export default createMiddleware({
defaultLocale: "en-us",
locales,
+ // This is the default. We can omit the
+ // option entirely and get the same result.
+ localeDetection: true,
});
export const config = {
// ...
};
Code language: Diff (diff)
🔗 Resource » We cover locale detection in our dedicated guide, Detecting a User’s Locale in a Web App.
next-intl uses a cascade to determine the active locale, stopping when it resolves the locale at any step:
1. Locale prefix in the request URI (e.g., /ar-eg/about
).
2. The NEXT_LOCALE
cookie, if it exists.
3. A locale matched from the Accept-Language
header (we just enabled this).
4. The defaultLocale
configured in the middleware.
When localeDetection
is active, next-intl tries to match the browser’s language preferences with our configured locales, optimizing for the closest linguistic and regional fit. For example, English variants match to en-us
, and Arabic preferences to ar-eg
, ensuring users see the most relevant language version of the site.
Notes and resources
- Under the hood, next-intl uses the @formatjs/intl-localematcher best-fit algorithm.
- When testing automatic locale matching remember to delete the
NEXT_LOCALE
cookie, since it will take precedence over user browser preferences when resolving the locale. - Read more about Locale detection in the next-intl docs.
How do I localize Client Components?
Next.js’ App Router typically uses React Server Components for server rendering, boosting performance and security. When components need browser-specific features like DOM events or React state, they can be designated as Client Components, which includes them in the client bundle and forces them to render in the browser.
While next-intl supports Server Components by default, it also offers ways to localize Client Components. Let’s add a mock weather alert Client Component to our home page to demonstrate. The component will have an accordion folding/unfolding UI that needs React state, which is only available in Client Components.
🗒️ Note » We made the language switcher we built a Client Component since we needed to listen to the DOM change event firing from its <select>
.
// app/_components/WeatherAlerts.tsx
// Tell Next.js that this is a Client
// Component.
"use client";
// ...
import { useState } from "react";
export default function WeatherAlerts() {
// We can only use React state in Client
// Components.
const [isOpen, setIsOpen] = useState(false);
const toggleAlerts = () => setIsOpen(!isOpen);
return (
<div>
<div
className="..."
onClick={toggleAlerts}
>
Weather Alerts
<span className="...">
▼ {/* Chevron down icon */}
</span>
</div>
{isOpen && (
<div className="...>
<p className="...">
🌩️ Severe Thunderstorm Warning until 09:00 PM
</p>
<p className="...">
🌨️ Blizzard Warning in effect from 01:00 AM
</p>
<p className="...">
🌊 Coastal Flood Advisory from noon today to
10:00 PM
</p>
</div>
)}
</div>
);
}
Code language: JavaScript (javascript)
Let’s drop our new WeatherAlerts
component into our home page.
// app/[locale]/page.tsx
+ import WeatherAlerts from "../_components/WeatherAlerts";
export default function Home() {
return (
<main>
{/* ... */}
<section className="...">
<div className="...">
<p className="...">☀️</p>
<p className="...">Sunny</p>
<p className="...">22°C</p>
</div>
</section>
+ <WeatherAlerts />
</main>
);
}
Code language: Diff (diff)
Since useTranslations
pulls translation messages on the server, we can’t use it in our Client Components. Fortunately, next-intl provides alternative ways for making our translations available to Client Components:
1. Passing translations to Client Components
2. Moving state to the server side
3. Providing individual messages
4. Providing all messages
We’ll focus on 1. passing translations directly, which allows Client Components to receive props from Server Components. This approach works well for our WeatherAlerts
component, where translations are fetched server-side and all interactive elements are managed client-side.
First, let’s rename our WeatherAlerts
component to ClientWeatherAlerts
and make it a presentational component.
// app/_components/WeatherAlerts/ClientWeatherAlerts.tsx
"use client";
// ...
import { useState } from "react";
- export default function WeatherAlerts() {
+ export default function ClientWeatherAlerts({
+ title,
+ children,
+ }: Readonly<{ title: string; children: React.ReactNode }>) {
const [isOpen, setIsOpen] = useState(false);
const toggleIsOpen = () => setIsOpen(!isOpen);
return (
<div>
<div
className="..."
onClick={toggleIsOpen}
>
- Weather Alerts
+ {title}
<span className="...">
▼ {/* Chevron down icon */}
</span>
</div>
{isOpen && (
<div className="...">
- <p className="...">
- 🌩️ Severe Thunderstorm Warning until 09:00 PM
- </p>
- // ...
+ {children}
</div>
)}
</div>
);
}
Code language: Diff (diff)
Now we can create a new ServerWeatherAlerts
component that injects our server-side translations, and mock fetched alerts, into our Client Component.
// app/_components/WeatherAlerts/ServerWeatherAlerts.tsx
import { type Locale } from "@/i18n.config";
import { useLocale, useTranslations } from "next-intl";
import ClientWeatherAlerts from "./ClientWeatherAlerts";
// In a production app, we would likely
// be fetching these from some service.
const mockWeatherAlerts = {
"en-us": [
"🌩️ Severe Thunderstorm Warning until 09:00 PM",
"🌨️ Blizzard Warning in effect from 01:00 AM",
"🌊 Coastal Flood Advisory from noon today to 10:00 PM",
],
"ar-eg": [
"🌩️ تحذير من عاصفة رعدية شديدة حتى الساعة 09:00 مساءً",
"🌨️ تحذير من عاصفة ثلجية قائمة بدءًا من الساعة 01:00 صباحًا",
"🌊 تنبيه من فيضان ساحلي من الظهيرة اليوم حتى الساعة 10:00 مساءً",
],
};
export default function ServerWeatherAlerts() {
const t = useTranslations("WeatherAlerts");
const locale = useLocale() as Locale;
const alerts = mockWeatherAlerts[locale];
return (
// Pass the translation message as a prop.
<ClientWeatherAlerts title={t("title")}>
{/* Inject alerts as children. */}
{alerts.map((alert) => (
<p className="..." key={alert}>
{alert}
</p>
))}
</ClientWeatherAlerts>
);
}
Code language: JavaScript (javascript)
We can use the useTranslations
and useLocale
hooks as normal in our Server Component, allowing us to grab our translations and pass them as props to our Client Component. Here are the new translation messages:
// locales/en-us.json
{
// ...
+ "WeatherAlerts": {
+ "title": "Weather Alerts"
+ }
}
// locales/ar-eg.json
{
// ...
+ "WeatherAlerts": {
+ "title": "تنبيهات الطقس"
+ }
}
Code language: Diff (diff)
Let’s swap our new Server Component into our home page to see our changes in action.
// app/[locale]/page.tsx
- import WeatherAlerts from "../_components/WeatherAlerts";
+ import ServerWeatherAlerts from "../_components/WeatherAlerts/ServerWeatherAlerts";
export default function Home() {
return (
<main>
{/* ... */}
<section className="...">
{/* ... */}
</section>
- <WeatherAlerts />
+ <ServerWeatherAlerts />
</main>
);
}
Code language: Diff (diff)
This composition pattern of wrapping Client Components inside Server Components allows our translations to load only on the server. The i18n library is never added to the client bundle, making our initial app load and client bundle as performant as possible.
🔗 Resource » Again, there are other ways next-intl provides for localizing Client Components. The Using internationalization in Client Components section of the next-intl docs covers them in detail.
🔗 Resource » Reminder that you can get all the app code we cover here from our GitHub repo.
How do I localize async components?
Up to this point, we’ve localized synchronous Server Components, which work out-of-the-box with next-intl. However, our weekly forecast page component needs to fetch data, which makes it a special case: an async component.
// app/[locale]/week/page.tsx
import type { WeeklyWeatherRoot } from "@/types";
import { promises as fs } from "fs";
// Note the `async` keyword here.
export default async function Week() {
// We `await` our file read.
const fileContents = await fs.readFile(
`${process.cwd()}/app/_data/week.json`,
"utf-8",
);
const { weeklyWeather } = JSON.parse(
fileContents,
) as WeeklyWeatherRoot;
return (
<main>
<h1 className="...">
This week's weather
</h1>
<div className="...">
{weeklyWeather.map((day) => (
// Display the day data
))}
</div>
</main>
);
}
Code language: JavaScript (javascript)
Async components like this throw an error if we call the useTranslations
hook from within them.
⨯ Internal error: Error: Expected a suspended thenable.
This is a bug in React. Please file an issue.
Code language: plaintext (plaintext)
Easy fix, however: next-intl provides an async drop-in replacement for useTranslations
called getTranslations
. We’ll use this function to localize our weekly weather page.
First, let’s add our new translations:
// locales/en-us.json
{
// ...
+ "Week": {
+ "title": "This week's weather",
+ "sunny": "Sunny",
+ "cloudy": "Cloudy",
+ "rainy": "Rainy",
+ "partlyCloudy": "Partly Cloudy",
+ "showers": "Showers",
+ "thunderstorms": "Thunderstorms"
}
}
Code language: Diff (diff)
// locales/ar-eg.json
{
// ...
+ "Week": {
+ "title": "طقص الأسبوع",
+ "sunny": "مشمس",
+ "cloudy": "غائم",
+ "rainy": "ممطر",
+ "partlyCloudy": "غائم جزئيا",
+ "showers": "زخات مطرية",
+ "thunderstorms": "عواصف رعدية"
}
}
Code language: Diff (diff)
Now we need to import the async getTranslations
function and use it like useTranslations
.
// app/[locale]/week/page.tsx
import type { WeeklyWeatherRoot } from "@/types";
import { promises as fs } from "fs";
+ import { getTranslations } from "next-intl/server";
export default async function Week() {
const fileContents = await fs.readFile(
`${process.cwd()}/app/_data/week.json`,
"utf-8",
);
const { weeklyWeather } = JSON.parse(
fileContents,
) as WeeklyWeatherRoot;
+ // We have to `await` here.
+ const t = await getTranslations("Week");
return (
<main>
<h1 className="...">
- This week's weather
+ {t("title")}
</h1>
<div className="...">
{weeklyWeather.map((day) => (
<section key={day.dateTime} className="...">
<h2 className="...">
{new Date(day.dateTime).toString()}
</h2>
<div>
<div className="...">
<p className="...">
{day.conditionIcon}
</p>
<p className="...">
- day.condition
+ {/* "sunny" | "partlyCloudy" | ... */}
+ {t(day.condition)}
</p>
<p className="...">
{day.temperature.celsius}°C
</p>
</div>
</div>
</section>
))}
</div>
</main>
);
}
Code language: Diff (diff)
🗒️ Note » We need to use getTranslations
in all async components, whether they’re page or shared components.
🔗 Resource » The next-intl Async components documentation section covers other async server functions like getLocale
and getFormatter
.
How do I ensure static rendering?
Next.js defaults Server Components to static rendering. We can see this if we build our site before it was localized by next-intl (use the start branch of our GitHub repo if you want to try this).
From the command line:
npm run build
Code language: Bash (bash)
All our routes are statically prerendered, which means they can be cached on the server, speeding up our page loads and saving compute time on the server.
If we build the site in its current state, after localization with next-intl, we see a different story. (Use the main branch of the GitHub repo if you want to try this yourself).
Due to next-intl APIs loading translations, our routes implicitly opt-in to dynamic rendering for each request. The next-intl team plans to address this in future updates, but they’ve provided a temporary workaround. Let’s implement it.
First, we need to address the [locale]
dynamic route param. Next.js doesn’t know how to fill values in that route segment during building unless we tell via the generateStaticParams function. Let’s add this function to our layout.
// app/[locale]/layout.tsx
import Header from "@/app/_components/Header";
import "@/app/globals.css";
- import { type Locale } from "@/i18n.config";
+ import { type Locale, locales } from "@/i18n.config";
import type { Metadata } from "next";
+ export function generateStaticParams() {
+ return locales.map((locale) => ({ locale }));
+ }
export const metadata: Metadata = {
// ...
};
export default function LocaleLayout({
children,
params: { locale },
}: Readonly<{
children: React.ReactNode;
params: { locale: Locale };
}>) {
return (
// ...
);
}
Code language: Diff (diff)
Next, we need to call next-intl’s workaround unstable_setRequestLocale
function, which makes the current locale available to all its APIs.
// app/[locale]/layout.tsx
import Header from "@/app/_components/Header";
import "@/app/globals.css";
import { type Locale, locales } from "@/i18n.config";
import type { Metadata } from "next";
+ import { unstable_setRequestLocale } from "next-intl/server";
export function generateStaticParams() {
return locales.map((locale) => ({ locale }));
}
export const metadata: Metadata = {
// ...
};
export default function LocaleLayout({
children,
params: { locale },
}: Readonly<{
children: React.ReactNode;
params: { locale: Locale };
}>) {
+ unstable_setRequestLocale(locale);
return (
// ...
);
}
Code language: Diff (diff)
Because Next.js can render layouts separately from pages, we need to call unstable_setRequestLocale
in the layout and all pages.
// app/[locale]/page.tsx
+ import { unstable_setRequestLocale } from "next-intl/server";
import WeatherAlerts from "../_components/WeatherAlerts/WeatherAlerts";
- export default function Home() {
+ export default function Home({
+ params: { locale },
+ }: Readonly<{ params: { locale: string } }>) {
+ unstable_setRequestLocale(locale);
return (
<main>
{/* ... */}
</main>
);
}
Code language: Diff (diff)
Repeat the above for each page in your app, adding the locale
param and passing it to unstable_setRequestedLocale
.
🗒️ Note » You need to call unstable_setRequestedLocale
before using any next-intl API like useTranslations
or you’ll get errors when you build.
With this in place, running npm run build
should reveal that we’re getting static rendering again.
Some notes on static rendering
- The
unstable_setRequestLocale
is a temporary solution due to Next.js limitations: Server Components can’t directly access route parameters likelocale
. Future next-intl updates may eliminate this requirement. Find out more in the next-intl Static rendering documentation. - Using
generateStaticParams
to specify every supported locale can impact build performance, especially for sites with many locales. It’s often more efficient to specify only the default locale and dynamically generate others as needed. - Statically exporting your site with next-intl has some important limitations. Refer to the Usage without middleware (static export) section in the next-intl documentation for guidance.
How do I work with right-to-left languages?
The Arabic version of our site has been looking awkward in its left-to-right (ltr
) orientation so far. Arabic, Hebrew, Maldivian, and other languages are laid out right-to-left (rtl
). Let’s accommodate rtl
languages in our app. We’ll do so via a simple custom hook.
First, let’s grab the rtl-detect NPM package, which we’ll use in our hook.
npm install rtl-detect
npm install --save-dev @types/rtl-detect
Code language: Bash (bash)
On to our custom hook:
// app/_hooks/useTextDirection.ts
import { useLocale } from "next-intl";
import { isRtlLang } from "rtl-detect";
export type TextDirection = "ltr" | "rtl";
export default function useTextDirection(): TextDirection {
const locale = useLocale();
return isRtlLang(locale) ? "rtl" : "ltr";
}
Code language: JavaScript (javascript)
We can now use our new hook in our app layout to ensure the <html dir>
attribute matches the active locale.
// app/[locale]/layout.tsx
// ...
import type { Metadata } from "next";
import { unstable_setRequestLocale } from "next-intl/server";
+ import useTextDirection from "../_hooks/useTextDirection";
// ...
export default function LocaleLayout(
// ...
) {
unstable_setRequestLocale(locale);
+ // Make sure this comes after the
+ // unstable_setRequestLocale call
+ // to avoid build errors.
+ const dir = useTextDirection();
return (
- <html lang={locale}>
+ <html lang={locale} dir={dir}>
<body className="...">
<Header />
{children}
</body>
</html>
);
}
Code language: Diff (diff)
With this in place, the html
tag will have dir="ltr"
when we’re on an English route and dir="rtl"
for Arabic routes.
🔗 Resource » There’s more to rtl
than just setting the <html dir>
attribute. We often need to consider which CSS selectors to use for a locale or text direction and how they affect our layouts. Our CSS Localization guide goes into detail about this and more.
How do I work with dynamic values in translation messages?
So far we’ve used static text in our translation messages. To inject runtime values into a translation we can use the {variable}
interpolation syntax. Let’s add a mock user greeting to our home page to demonstrate.
// locales/en-us.json
{
// ...
"WeatherAlerts": {
"title": "Weather Alerts"
},
+ "Home": {
+ // `{name}` will be replaced at runtime
+ "userGreeting": "👋 Welcome, {name}!"
+ },
// ...
}
Code language: Diff (diff)
// locales/ar-eg.json
{
// ...
"WeatherAlerts": {
"title": "تنبيهات الطقس"
},
+ "Home": {
+ "userGreeting": "👋 مرحباً {name}"
+ },
// ...
}
Code language: Diff (diff)
// app/[locale]/page.tsx
+ import { useTranslations } from "next-intl";
import { unstable_setRequestLocale } from "next-intl/server";
import WeatherAlerts from "../_components/WeatherAlerts/WeatherAlerts";
export default function Home({
params: { locale },
}: Readonly<{ params: { locale: string } }>) {
unstable_setRequestLocale(locale);
+ const t = useTranslations("Home");
return (
<main>
+ <p className="...">
+ // We supply a key/value map of
+ // dynamic values we want to replace.
+ {t("userGreeting", { name: "Noor" })}
+ </p>
{/* ... */}
</main>
);
}
Code language: Diff (diff)
We can have as many {variable}
s in our message as we desire. We just need to make sure that we have an equivalent {variable: "Value"}
in the second param passed to t()
or our message won’t render correctly.
🔗 Resource » Reminder that you can get all the code for our demo app from our GitHub repo.
How do I work with localized plurals?
Plurals often need special treatment in translation messages. We need to provide the different plural forms (”one message”, “three messages”) and an integer count that is used to select the correct form. next-intl wisely implements plurals with the flexible ICU Message Format, which looks like the following:
"messageCount":
"{count, plural,
one {One message}
other {# messages}
}"
Code language: Diff (diff)
English has two plural forms: one
and other
, which we specify above. We can then utilize the above English message with a call like t("messageCount", { count: 4 })
. next-intl would then select the other
form and render “4 messages”. Note that the count
variable will replace the special #
character in our messages.
🗒️ Note » We can’t have line breaks in our JSON files. The above line breaks were added to clarify the formatting. We’ll see a message like this in JSON in a moment.
For a concrete example, let’s add a weather alert counter to our weekly weather page. First, we’ll add the plural translation messages.
// locales/en-us.json
{
// ...
"Home": {
"userGreeting": "👋 Welcome, {name}!"
},
"Week": {
+ "alertCount": "{count, plural, =0 {No alerts} one {One alert!} other {# alerts!}}",
"sunny": "Sunny",
"cloudy": "Cloudy",
// ...
}
}
Code language: Diff (diff)
We can use a special =n
plural form where n
is an integer override, allowing us to target a specific count
value not covered by the language’s built-in plural forms. The =0
form above is a special zero count message that will override the other
form when count === 0
.
Now for the Arabic message. Unlike English, Arabic has six plural forms!
// locales/ar-eg.json
{
// ...
"Home": {
"userGreeting": "👋 مرحباً {name}"
},
"Week": {
+ "alertCount": "{count, plural, zero {لا توجد تنبيهات} one {يوجد تنبيه واحد} two {يوجد تنبيهان} few {يوجد {count, number} تنبيهات} many {يوجد {count, number} تنبيه} other {يوجد {count, number} تنبيه}}",
"sunny": "مشمس",
// ...
}
}
Code language: Diff (diff)
Note how we’re using {count, number}
instead of #
for count
interpolation in our Arabic messages. This practice ensures our numbers are rendered in the correct numeral system for the active locale.
🔗 Resource » The canonical source for languages’ plural forms is the CLDR Language Plural Rules chart.
🔗 Resource » This Online ICU Message Editor is handy when formatting plural forms.
Alright, let’s add the weekly alert counter to utilize these messages.
// app/[locale]/week/page.tsx
// ...
export default async function Week({
params: { locale },
}: Readonly<{ params: { locale: string } }>) {
// ...
const t = await getTranslations("Week");
return (
<main>
<div className="...">
<h1 className="...">{t("title")}</h1>
+ <p className="...">
+ {t("alertCount", { count: 3 })}
+ </p>
</div>
<div className="...">
{/* ... */}
</div>
</main>
);
}
Code language: Diff (diff)
Given the value of count
and the active
locale, we’ll see our new alert counter rendered with the correct plural form.
Notes and resources
- For more on the kinds of plurals next-intl supports, check out the Cardinal pluralization and Ordinal pluralization sections of the official docs.
- If you’re wondering what the ICU Message Format is, our Practical Guide to the ICU Message Format has you covered.
- We cover plural localization in greater detail in our dedicated Guide to Localizing Plurals.
How do I localize numbers?
Not all regions use Western Arabic numerals (1, 2, 3). For instance, Tamil employs the Tamil numeral system (௦, ௧, ௨, ௩). Symbols for currency and large number separators vary by locale: In India, numbers are often separated by lakhs and crores (1,00,000 and 1,00,00,000) instead of thousands and millions (1,000 and 1,000,000).
🗒️ Note » Numbers and dates are often region-specific not language-specific, so use region-specific locale codes. Use en-us
, not en
, for example.
next-intl provides two main ways to format numbers: standalone and in messages. Standalone numbers are formatted using the format.number()
function. Let’s use this on our home page to localize the day’s temperature.
//app/[locale]/page.tsx
- import { useTranslations } from "next-intl";
+ // Import the `useFormatter` hook
+ import { useFormatter, useTranslations } from "next-intl";
// ...
export default function Home({
params: { locale },
}: Readonly<{ params: { locale: string } }>) {
const t = useTranslations("Home");
+ const format = useFormatter();
unstable_setRequestLocale(locale);
return (
<main>
{/* ... */}
<section className="...">
<div className="...">
<p className="...">☀️</p>
<p className="...">
{t("sunny")}
</p>
- <p className="...">22°C</p>
+ <p className="...">
+ {format.number(22, {
+ style: "unit",
+ unit: "celsius",
+ })}
+ </p>
</div>
</section>
<WeatherAlerts />
</main>
);
}
Code language: Diff (diff)
🗒️ Note » In async components, we need to use getFormatter instead of useFormatter
.
Under the hood next-intl uses the standard Intl.NumberFormat to format our numbers, and it passes any options in the second param to format.number()
along to Intl.NumberFormat
.
🔗 Resource » See the Intl.NumberFormat constructor Parameters section of the MDN docs for a listing of all available number formatting options.
We sometimes need our numbers embedded in translation messages, and next-intl allows us to do this via ICU skeletons. These are just formatting patterns that start with ::
. Let’s see them in action as we add a mock “we’ve been live for this long” message to our About page.
First, the messages:
// locales/en-us.json
{
// ...
"Week": {
// ...
"showers": "Showers",
"thunderstorms": "Thunderstorms"
},
"About": {
+ "liveDuration": "We've been live for {duration, number, ::precision-integer} seconds."
}
}
Code language: Diff (diff)
Note the {variableName, number [, ::skeleton]}
syntax. The ::precision-integer
skeleton causes the number to be rounded to the nearest whole number.
// locales/ar-eg.json
{
// ...
"Week": {
// ...
"showers": "زخات مطرية",
"thunderstorms": "عواصف رعدية"
},
"About": {
+ "liveDuration": "لقد كنا على الهواء مباشرة لمدة {duration, number, ::precision-integer} ثانية."
}
}
Code language: Diff (diff)
Let’s use these messages on our About page.
// app/[locale]/about/page.tsx
import { useTranslations } from "next-intl";
import { unstable_setRequestLocale } from "next-intl/server";
export default function About({
params: { locale },
}: Readonly<{ params: { locale: string } }>) {
unstable_setRequestLocale(locale);
const t = useTranslations("About");
return (
<main>
{/* ... */}
<p className="...">
+ {t("liveDuration", { duration: 17280000.45 })}
</p>
</main>
);
}
Code language: Diff (diff)
ICU skeletons offer a lot of convenience and flexibility. Prebuilt skeletons like ::currency/USD
or ::percent
will use appropriate formatting for the active locale. In addition, ICU skeletons can offer granular control over number formats. For example, the ::.##/@##r
skeleton will format a number with at most 2 fraction digits, but guarantee at least 3 significant digits.
Notes and resources
- See the Number Skeletons page in the ICU documentation for available skeletons. Heads up, however: While many ICU skeletons work with next-intl, not all do. Be sure to test out the skeletons to ensure they work.
- next-intl allows us to define global custom formats for reuse across our app. Check out the Formats documentation for more details.
- Our Concise Guide to Number Localization covers numeral systems, separators, currency, and more.
How do I localize dates and times?
Date formatting also varies across regions. For instance, the format often used in the United States is MM/DD/YYYY
, whereas many European countries use DD/MM/YYYY
.
🗒️ Note » Date and time localization is similar to number localization in many ways, so we encourage you to read the previous section if you haven’t already.
Much like numbers, next-intl gives us two main ways of formatting dates and times: standalone and in messages. We’ll cover standalone formatting first. Let’s localize the date on our home page as we do.
// app/[locale]/page.tsx
import { useFormatter, useTranslations } from "next-intl";
// ...
export default function Home({
params: { locale },
}: Readonly<{ params: { locale: string } }>) {
const t = useTranslations("Home");
+ // We use the same `userFormatter`
+ // hook that used did for numbers.
const format = useFormatter();
unstable_setRequestLocale(locale);
return (
<main>
{/* ... */}
<h1 className="...">{t("title")}</h1>
<h2 className="...">
- Monday April 15 2024
+ {format.dateTime(new Date("2024-04-15"), {
+ dateStyle: "full",
+ })}
</h2>
{/* ... */}
</main>
);
}
Code language: Diff (diff)
The format.dateTime
function is locale-aware, and will format its given Date
object per the rules of the active locale. Under the hood, next-intl uses the standard Intl.DateTimeFormat, passing it any options we give it as the second param to t()
.
🔗 Resource » See the Intl.DateTimeFormat constructor Parameters section of the MDN docs for a listing of available datetime formatting options.
We can embed dates in our translation messages. Let’s add a message to our weekly forecast page that displays the day’s date to demonstrate.
// locales/en-us.json
{
// ...
"Week": {
"title": "This week's weather",
+ "dayDate": "{dayDate, date, ::EEEE}",
// ...
},
// ...
}
Code language: Diff (diff)
// locales/ar-eg.json
{
// ...
"Week": {
"title": "الطقس لهذا الأسبوع",
+ "dayDate": "{dayDate, date, ::EEEE}",
// ...
},
// ...
}
Code language: Diff (diff)
We can use the ICU {variable, date [, ::skeleton]}
syntax to format dates in our message. The ::EEEE
ICU datetime skeleton above should display the date as a long weekday e.g. “Thursday”.
🔗 Resource » next-intl only supports a subset of ICU datetime skeletons. See the Dates and times within messages section of the documentation for a listing of supported skeletons.
Let’s use our new messages on our weekly forecast page.
// app/[locale]/week/page.tsx
import type { WeeklyWeatherRoot } from "@/types";
import {
+ getFormatter,
getTranslations,
unstable_setRequestLocale,
} from "next-intl/server";
export default async function Week({
params: { locale },
}: Readonly<{ params: { locale: string } }>) {
// ...
const t = await getTranslations("Week");
+ // Remember to `await` here.
+ const format = await getFormatter();
return (
<main>
{/* ... */}
<div className="...">
{weeklyWeather.map((day) => (
<section key={day.dateTime} className="...">
<h2 className="...">
- {new Date(day.dateTime).toString()}
+ {t("dayDate", {
+ dayDate: new Date(day.dateTime),
+ })}
</h2>
{/* ... */}
</section>
))}
</div>
</main>
);
}
Code language: Diff (diff)
Recall that we’ll get an error if we call the useFormatter
hook within our async Week
component. We have to use the async getFormatter instead.
Notes and resources
- The eagle-eyed reader will have noticed that the English format above does not show the full day of the week (”Monday”), but the short version instead (”Mon”). This seems to be an issue with next-intl at the time of writing. We were able to work around it by using a custom global format.
- By default, the server’s time zone is used when formatting dates. See the Time zone documentation under Global configuration if you want to use a different time zone.
- In addition to absolute datetime formatting, next-intl provides options for formatting relative times and date ranges.
- Our Guide to Date and Time Localization covers the subject in detail.
How do I include HTML in my translation messages?
Different languages have different grammar, so it’s often wise to leave internal links or style emphases (italics, bold) for translators to place in messages. However, we don’t want translators to worry about the intricacies of HTML.
next-intl solves this with the t.rich
function. Let’s use the function to localize the description text in our About page. We can include a link in the description message for each locale:
// locales/en-us.json
{
// ...
"About": {
"title": "About",
+ "description": "This is a minimalistic mock weather app built with <linkToNext>Next.js</linkToNext>.",
// ...
}
}
Code language: Diff (diff)
// locales/ar-eg.json
{
// ...
"About": {
"title": "نبذة عنا",
+ "description": "هذا تطبيق طقس وهمي بسيط تم إنشاؤه باستخدام <linkToNext>نكست چى أس</linkToNext>.",
// ...
}
}
Code language: Diff (diff)
Note that our messages use a custom <linkToNext>
tag to indicate the text we’re linking. We can call this tag whatever we want. We can also use as many tags as we want in a message as long as we resolve them when we call t.rich
.
// app/[locale]/about/page.tsx
import { useTranslations } from "next-intl";
import { unstable_setRequestLocale } from "next-intl/server";
export default function About({
params: { locale },
}: Readonly<{ params: { locale: string } }>) {
unstable_setRequestLocale(locale);
const t = useTranslations("About");
return (
<main>
<h1 className="...">{t("title")}</h1>
<p className="...">
- This is a minimalistic mock weather app...
+ {t.rich("description", {
+ linkToNext: (chunks) => (
+ <a
+ href="https://nextjs.org"
+ className="text-sky-200 underline"
+ >
+ {chunks}
+ </a>
+ ),
+ })}
</p>
{/* ... */}
</main>
);
}
Code language: Diff (diff)
t.rich
takes two parameters: the key of our translation message and a map of tags to resolvers. Each resolver is a simple function that takes as a chunks
param, a string of the inner contents between <tag>
and </tag>
in the translation message. This allows using any React component when resolving our custom linkToNext
tag. Here we’re using simple JSX that outputs an <a>
tag.
🔗 Resource » The Rich text section of the next-intl documentation covers tag reuse, self-closing tags, and more.
🔗 Resource » Sometimes we need to output a raw string; we can achieve this with the t.markup
function. Find out more in the HTML markup section of the next-intl docs.
How do I localize page metadata?
Since metadata is managed outside of component rendering, we must utilize next-intl’s async getTranslator
function to localize it. We’ll need to use Next.js’ async generateMetadata function as well.
Let’s localize our layout’s metadata to demonstrate. First, our messages:
// locales/en-us.json
{
// ...
+ "Layout": {
+ "metaData": {
+ "title": "Next.js Weather",
+ "description": "A weather app built with Next.js and next-intl"
+ }
+ },
// ...
}
Code language: Diff (diff)
// locales/ar-eg.json
{
// ...
+ "Layout": {
+ "metaData": {
+ "title": "تقص نكست چى إس",
+ "description": "تطبيق طقس وهمي بسيط تم إنشاؤه باستخدام Next.js و next-intl."
+ }
+ },
// ...
}
Code language: Diff (diff)
Now we can use these messages to localize our layout’s metadata.
// app/[locale]/layout.tsx
// ...
import { Locale, locales } from "@/i18n.config";
import {
+ getTranslations,
unstable_setRequestLocale,
} from "next-intl/server";
import useTextDirection from "../_hooks/useTextDirection";
export function generateStaticParams() {
return locales.map((locale) => ({ locale }));
}
- export const metadata: Metadata = {
- title: "Next.js Weather",
- description:
- "A weather app built with Next.js and next-intl",
- };
+ // We pull in the current locale
+ // generated from `generateStaticParms`
+ // or the current request route.
+ export async function generateMetadata({
+ params: { locale },
+ }: {
+ params: { locale: Locale };
+ }) {
+ const t = await getTranslations({
+ locale,
+ namespace: "Layout.metaData",
+ });
+
+ return {
+ title: t("title"),
+ description: t("description"),
+ };
+ }
export default function LocaleLayout(
// ...
) {
// ...
}
Code language: Diff (diff)
Now we can see our site’s title and description translated to the active locale.
Of course, we can override the layout values by applying the above recipe to any of our page components.
🔗 Resource » The Metadata & Route Handlers documentation covers metadata files (like OpenGraph images), route handlers, and more.
How do I make my message keys type-safe?
next-intl supports TypeScript out-of-the-box, but that doesn’t cover our app-specific message keys. By default, these are treated as a string type. We can tighten this up and make t()
only accept keys we’ve defined in our default translation file. To accomplish this we need to redeclare next-intl’s IntlMessages type.
// types.ts
+ import enUSMessages from "./locales/en-us.json";
+ type Messages = typeof enUSMessages;
+ declare global {
+ interface IntlMessages extends Messages {}
+ }
export interface WeeklyWeatherRoot {
weeklyWeather: WeeklyWeather[];
}
// ...
Code language: Diff (diff)
Our particular app feeds weather condition keys to t()
to map a key like "sunny"
to its translation, "Sunny"
(en-us
) or "مشمس"
(ar-eg
). This means we need to update our types.ts
file so that we’re always passing a compatible key type to t()
.
import enus from "./locales/en-us.json";
type Messages = typeof enus;
declare global {
interface IntlMessages extends Messages {}
}
export interface WeeklyWeatherRoot {
weeklyWeather: WeeklyWeather[];
}
+ export type Condition =
+ | "sunny"
+ | "cloudy"
+ | "rainy"
+ | "thunderstorms"
+ | "showers";
export interface WeeklyWeather {
dateTime: string;
- condition: string;
+ condition: Condition;
conditionIcon: string;
temperature: Temperature;
}
// ...
Code language: Diff (diff)
With this in place, if we try to call t()
with a key not defined in our English message file, TypeScript will give us a type error.
That will do it for this tutorial. Here’s a look at our final localized app.
🔗 Resource » You can get all the code of the demo app from our GitHub repo.
Push the boundaries of Next.js localization
We hope you’ve found this guide to localizing Next.js with the App Router and next-intl library helpful.
When you’re all set to start the translation process, rely on the Phrase Localization Platform to handle the heavy lifting. A specialized software localization platform, the Phrase Localization Platform comes with dozens of tools designed to automate your translation process and native integrations with platforms like GitHub, GitLab, and Bitbucket.
With its user-friendly strings editor, translators can effortlessly access your content and transfer it into your target languages. Once your translations are set, easily integrate them back into your project with a single command or automated sync.
This way, you can stay focused on what you love—your code. Sign up for a free trial and see for yourself why developers appreciate using The Phrase Localization Platform for software localization.
Last updated on April 29, 2024.