Next.js course · Module 9: Integrations and Advanced Features
Internationalization with next-intl
In this lesson9
The Metropolis Quantum 2150 store is visited by residents of Warsaw, New York and Berlin. If texts are hard-coded in components, every new language means copying the whole application, and dates and prices still show up in the wrong format. Neo-Bot, the translator of the Metropolis, solves this with the next-intl library - the most popular internationalization (i18n) tool for the Next.js App Router.
Why next-intl?
Next.js does not ship a built-in i18n library for the App Router, only routing patterns. next-intl fills this gap by offering:
- Full support for Server Components
- Type-safe translations with TypeScript
- Formatting of dates, numbers and currencies
- Pluralization support
- ICU Message Format
- Locale-based routing
The rollout always follows this order: you define the supported languages, create the translation files, configure a middleware that detects the language, and only then display translations in components.
Installation and configuration
Step 1: Installation
The library is a single package that works in both server and client components:
1npm install next-intlNothing changes after installation yet, because next-intl has to be configured in a few files.
Step 2: Translation file structure
We keep translations in the messages directory, one JSON file per language (locale):
1messages/
2├── pl.json
3├── en.json
4└── de.jsonKeys are grouped into namespaces, such as common or products. This is the Polish file:
1// messages/pl.json
2{
3 "common": {
4 "welcome": "Witaj w Metropolii Quantum!",
5 "login": "Zaloguj się",
6 "logout": "Wyloguj się",
7 "loading": "Ładowanie..."
8 },
9 "home": {
10 "title": "Strona główna",
11 "description": "Odkryj przyszłość technologii",
12 "cta": "Rozpocznij przygodę"
13 },
14 "products": {
15 "title": "Produkty",
16 "price": "Cena: {price, number, ::currency/PLN}",
17 "inStock": "{count, plural, =0 {Brak w magazynie} one {# sztuka} few {# sztuki} many {# sztuk} other {# sztuk}}",
18 "addToCart": "Dodaj do koszyka"
19 }
20}The values in braces are ICU Message Format. {price, number, ::currency/PLN} formats a number as currency, and plural picks a form depending on the number. Polish needs the one, few and many categories, because it says "1 sztuka", "3 sztuki", "5 sztuk".
The English file has the same keys but simpler pluralization, because English only knows one and other:
1// messages/en.json
2{
3 "common": {
4 "welcome": "Welcome to Quantum Metropolis!",
5 "login": "Log in",
6 "logout": "Log out",
7 "loading": "Loading..."
8 },
9 "home": {
10 "title": "Home",
11 "description": "Discover the future of technology",
12 "cta": "Start your journey"
13 },
14 "products": {
15 "title": "Products",
16 "price": "Price: {price, number, ::currency/USD}",
17 "inStock": "{count, plural, =0 {Out of stock} one {# item} other {# items}}",
18 "addToCart": "Add to cart"
19 }
20}Keys must be identical in every file, only the values change.
Step 3: Configuring next-intl
First, a single source of truth about languages. as const turns the array into a tuple of literals, from which we derive the Locale type:
1// i18n/config.ts
2export const locales = ['pl', 'en', 'de'] as const;
3export const defaultLocale = 'pl' as const;
4
5export type Locale = (typeof locales)[number];Now TypeScript knows that Locale is exactly 'pl' | 'en' | 'de'.
The i18n/request.ts file tells the library which translations to load for a given request. The getRequestConfig function receives requestLocale, a promise with the language read from the URL:
1// i18n/request.ts
2import { getRequestConfig } from 'next-intl/server';
3import { locales, defaultLocale } from './config';
4
5export default getRequestConfig(async ({ requestLocale }) => {
6 // Validation locale
7 let locale = await requestLocale;
8 if (!locale || !locales.includes(locale as any)) {
9 locale = defaultLocale;
10 }
11
12 return {
13 locale,
14 messages: (await import(`../messages/${locale}.json`)).default,
15 timeZone: 'Europe/Warsaw',
16 now: new Date(),
17 };
18});We replace an unknown language with the default one, and the function returns locale together with the messages, the time zone and the current date. requestLocale works in next-intl 4, although the docs of the latest releases mark it as the older approach and read the language through next/root-params in Next.js 16.3+.
Step 4: Middleware
The middleware detects the language from the URL or the browser headers and redirects to the right path. In Next.js 16 this file is called proxy.ts:
1// middleware.ts (Next.js 16: proxy.ts)
2import createMiddleware from 'next-intl/middleware';
3import { locales, defaultLocale } from './i18n/config';
4
5export default createMiddleware({
6 locales,
7 defaultLocale,
8 localePrefix: 'as-needed', // or 'always' or 'never'
9});
10
11export const config = {
12 matcher: [
13 // All paths except API, _next, static files
14 '/((?!api|_next|_vercel|.*\..*).*)',
15 ],
16};localePrefix: 'as-needed' hides the prefix for the default language, so the Polish page lives at /products and the English one at /en/products. The matcher skips the API, Next.js files and static files.
Step 5: Folder structure with [locale]
We move all pages into the dynamic [locale] segment, which makes the language part of the URL:
1app/
2├── [locale]/
3│ ├── layout.tsx
4│ ├── page.tsx
5│ ├── products/
6│ │ ├── page.tsx
7│ │ └── [id]/
8│ │ └── page.tsx
9│ └── about/
10│ └── page.tsx
11├── api/
12└── globals.cssThe api directory stays outside the segment, because endpoints do not need language versions.
Step 6: Root Layout with locale
The layout in [locale] becomes the root layout of the app. generateStaticParams generates static versions for every language, and params has been a promise since Next.js 15 (in Next.js 16 it can no longer be read synchronously):
1// app/[locale]/layout.tsx
2import { NextIntlClientProvider } from 'next-intl';
3import { getMessages } from 'next-intl/server';
4import { locales } from '@/i18n/config';
5import { notFound } from 'next/navigation';
6
7export function generateStaticParams() {
8 return locales.map((locale) => ({ locale }));
9}
10
11export default async function LocaleLayout({
12 children,
13 params,
14}: {
15 children: React.ReactNode;
16 params: Promise<{ locale: string }>;
17}) {
18 const { locale } = await params;
19
20 // Validation locale
21 if (!locales.includes(locale as any)) {
22 notFound();
23 }
24
25 const messages = await getMessages();
26
27 return (
28 <html lang={locale}>
29 <body>
30 <NextIntlClientProvider messages={messages}>
31 {children}
32 </NextIntlClientProvider>
33 </body>
34 </html>
35 );
36}An unsupported language ends with a 404 page. NextIntlClientProvider makes translations available to client components, and in next-intl 4 it inherits the messages automatically, so the messages prop is optional.
Using translations
In Server Components
In server components you use the async getTranslations function with a namespace name:
1// app/[locale]/page.tsx
2import { getTranslations } from 'next-intl/server';
3
4export default async function HomePage() {
5 const t = await getTranslations('home');
6 const tCommon = await getTranslations('common');
7
8 return (
9 <main>
10 <h1>{t('title')}</h1>
11 <p>{t('description')}</p>
12 <button>{t('cta')}</button>
13 <p>{tCommon('welcome')}</p>
14 </main>
15 );
16}
17
18// Generating metadata with translations
19export async function generateMetadata({ params }: Props) {
20 const { locale } = await params;
21 const t = await getTranslations({ locale, namespace: 'home' });
22
23 return {
24 title: t('title'),
25 description: t('description'),
26 };
27}The t function returns the text for a key in the chosen namespace. generateMetadata uses it with an explicit locale, so the page title is translated too.
In Client Components
In client components the useTranslations hook gives you the same interface:
1// components/ProductCard.tsx
2'use client';
3
4import { useTranslations } from 'next-intl';
5
6interface ProductCardProps {
7 product: {
8 id: string;
9 name: string;
10 price: number;
11 stock: number;
12 };
13}
14
15export function ProductCard({ product }: ProductCardProps) {
16 const t = useTranslations('products');
17
18 return (
19 <div className="product-card">
20 <h3>{product.name}</h3>
21 <p>{t('price', { price: product.price })}</p>
22 <p>{t('inStock', { count: product.stock })}</p>
23 <button>{t('addToCart')}</button>
24 </div>
25 );
26}The second argument of t passes variables into the message, so { count: product.stock } picks the right plural form. The component does not know the language, the provider does.
Formatting
Dates and time
The useFormatter hook formats dates according to the rules of the current language, using the browser's Intl API:
1import { useFormatter } from 'next-intl';
2
3function EventDate({ date }: { date: Date }) {
4 const format = useFormatter();
5
6 return (
7 <div>
8 {/* Full date */}
9 <p>{format.dateTime(date, { dateStyle: 'full' })}</p>
10
11 {/* Date only */}
12 <p>{format.dateTime(date, {
13 year: 'numeric',
14 month: 'long',
15 day: 'numeric'
16 })}</p>
17
18 {/* Relative time */}
19 <p>{format.relativeTime(date)}</p>
20
21 {/* Date range */}
22 <p>{format.dateTimeRange(startDate, endDate, {
23 dateStyle: 'medium'
24 })}</p>
25 </div>
26 );
27}The same code produces "sobota, 26 września 2026" in Polish and "Saturday, September 26, 2026" in English.
Numbers and currencies
The same rules apply to numbers, percentages, units and lists. The thousands separator, the currency symbol and the list conjunction depend on the language:
1import { useFormatter } from 'next-intl';
2
3function PriceDisplay({ amount }: { amount: number }) {
4 const format = useFormatter();
5
6 return (
7 <div>
8 {/* Currency */}
9 <p>{format.number(amount, { style: 'currency', currency: 'PLN' })}</p>
10
11 {/* Percent */}
12 <p>{format.number(0.25, { style: 'percent' })}</p>
13
14 {/* Units */}
15 <p>{format.number(1500, {
16 style: 'unit',
17 unit: 'kilometer',
18 unitDisplay: 'long'
19 })}</p>
20
21 {/* Lists */}
22 <p>{format.list(['React', 'Next.js', 'TypeScript'], { type: 'conjunction' })}</p>
23 </div>
24 );
25}format.list inserts "i" in Polish and "and" in English, without any condition in the code.
Language switcher
Language-aware navigation comes from the createNavigation function. You create it once, in a separate file:
1// i18n/navigation.ts
2import { createNavigation } from 'next-intl/navigation';
3import { locales, defaultLocale } from './config';
4
5export const { Link, redirect, usePathname, useRouter } = createNavigation({
6 locales,
7 defaultLocale,
8});These versions of Link, useRouter and usePathname know the list of languages and add the prefix to the URL themselves. The switcher uses them like this:
1// components/LocaleSwitcher.tsx
2'use client';
3
4import { useLocale } from 'next-intl';
5import { usePathname, useRouter } from '@/i18n/navigation';
6import { locales } from '@/i18n/config';
7
8const localeNames: Record<string, string> = {
9 pl: 'Polski',
10 en: 'English',
11 de: 'Deutsch',
12};
13
14export function LocaleSwitcher() {
15 const locale = useLocale();
16 const router = useRouter();
17 const pathname = usePathname();
18
19 const handleChange = (newLocale: string) => {
20 router.replace(pathname, { locale: newLocale });
21 };
22
23 return (
24 <select
25 value={locale}
26 onChange={(e) => handleChange(e.target.value)}
27 className="bg-gray-800 text-white px-3 py-2 rounded"
28 >
29 {locales.map((loc) => (
30 <option key={loc} value={loc}>
31 {localeNames[loc]}
32 </option>
33 ))}
34 </select>
35 );
36}router.replace(pathname, { locale: newLocale }) stays on the same page and changes only the language. The old import from next-intl/client does not exist in current versions of the library.
Links that keep the locale
The Link from the same file automatically adds the prefix of the current language:
1import { Link } from '@/i18n/navigation';
2
3function Navigation() {
4 return (
5 <nav>
6 {/* Link automatycznie dodaje prefix locale */}
7 <Link href="/">Home</Link>
8 <Link href="/products">Products</Link>
9 <Link href="/about">About</Link>
10
11 {/* Link do konkretnego locale */}
12 <Link href="/about" locale="en">About (EN)</Link>
13 </nav>
14 );
15}The locale prop lets you point to another language, for example in a footer listing the language versions.
Type-safe translations
We can derive the message type from the Polish JSON file and register it in next-intl by augmenting the AppConfig interface:
1// types/messages.ts
2import pl from '../messages/pl.json';
3
4// Automatic typing from the JSON file
5type Messages = typeof pl;
6
7declare module 'next-intl' {
8 interface AppConfig {
9 Messages: Messages;
10 }
11}Now TypeScript will suggest the available keys and also check that you pass the required message parameters:
1const t = useTranslations('products');
2t('title'); // OK
3t('price', { price: 100 }); // OK z parametrem
4t('nonExistent'); // TypeScript error!A typo in a key stops the build instead of showing the user the raw text products.nonExistent.
Practical example - E-commerce
The products page combines server-side translation with the ProductCard client component from the earlier section:
1// app/[locale]/products/page.tsx
2import { getTranslations } from 'next-intl/server';
3import { ProductCard } from '@/components/ProductCard';
4
5export default async function ProductsPage() {
6 const t = await getTranslations('products');
7 const products = await getProducts();
8
9 return (
10 <div className="container mx-auto py-8">
11 <h1 className="text-3xl font-bold mb-8">{t('title')}</h1>
12
13 <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
14 {products.map((product) => (
15 <ProductCard key={product.id} product={product} />
16 ))}
17 </div>
18 </div>
19 );
20}The server translates the heading, and the cards translate their own labels. The extended Polish file adds filters, sorting and a result counter:
1// Extended messages/pl.json
2{
3 "products": {
4 "title": "Nasze produkty",
5 "filters": {
6 "all": "Wszystkie",
7 "inStock": "Dostępne",
8 "onSale": "W promocji"
9 },
10 "sort": {
11 "label": "Sortuj",
12 "priceAsc": "Cena: od najniższej",
13 "priceDesc": "Cena: od najwyższej",
14 "newest": "Najnowsze"
15 },
16 "empty": "Nie znaleziono produktów",
17 "results": "{count, plural, =0 {Brak wyników} one {# produkt} few {# produkty} many {# produktów} other {# produktów}}"
18 }
19}You read nested objects, such as filters or sort, with a dotted key, for example t('filters.all').
Summary
next-intl offers a complete i18n solution for Next.js:
- Server Components - full support with
getTranslations - Client Components - the
useTranslationsanduseFormatterhooks - Type Safety - TypeScript suggests the available keys
- Formatting - dates, numbers, currencies, lists
- Routing - automatic locale prefixes in the URL
- Pluralization - correct inflection for every language
My advice: keep texts in JSON files from day one, even if today you support a single language. In the next lesson you will package the application in a Docker container.
Remember: in Metropolis Quantum a component knows only the keys, and next-intl picks the language, the plural forms and the date format for it.
Code for this lesson: App.tsx
1import React, { useState } from 'react';
2
3// Create API route for Checkout Session
4
5export default function CheckoutSessionAPI() {
6 const [requestBody, setRequestBody] = useState(JSON.stringify({
7 priceId: 'price_1234',
8 quantity: 1,
9 successUrl: '/success',
10 cancelUrl: '/cancel',
11 }, null, 2));
12 const [response, setResponse] = useState<any>(null);
13 const [isLoading, setIsLoading] = useState(false);
14
15 // TODO: Implement simulateCreateSession
16 // Parse requestBody, validate, return session URL
17
18 return (
19 <div style={{ background: '#0f0f23', minHeight: '100vh', padding: 20, color: '#e0e0e0', fontFamily: 'monospace' }}>
20 <h1 style={{ color: '#64ffda' }}>Checkout Session API</h1>
21 <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 20 }}>
22 <div>
23 <h3>Request Body</h3>
24 <textarea value={requestBody} onChange={e => setRequestBody(e.target.value)}
25 style={{ width: '100%', height: 200, background: '#0d1117', color: '#e0e0e0', border: '1px solid #333',
26 borderRadius: 8, padding: 10, fontFamily: 'monospace', fontSize: 12 }} />
27 {/* TODO: "Send request" button */}
28 </div>
29 <div>
30 <h3>Response</h3>
31 {/* TODO: Display response JSON */}
32 </div>
33 </div>
34 </div>
35 );
36}Spotted a mistake in this lesson?
Check yourself
Answer the questions from this lesson. Pick an answer to see right away whether it is correct.
1. next-intl in Next.js is used for:
2. What does the next-intl library provide in Next.js applications?
Hands-on tasks in the game
- Code editor
Build an interface with multilingual support (i18n)
- Vertical ordering
Arrange the steps of implementing internationalization (i18n) in Next.js