Kurs Next.js · Moduł 9: Integracje i zaawansowane funkcje
Internacjonalizacja z next-intl
W tej lekcji9
Sklep Metropolii Quantum 2150 odwiedzają mieszkańcy z Warszawy, Nowego Jorku i Berlina. Jeśli teksty są wpisane na sztywno w komponentach, każdy nowy język oznacza kopiowanie całej aplikacji, a daty i ceny i tak wyświetlają się w złym formacie. Neo-Bot, tłumacz Metropolii, rozwiązuje to biblioteką next-intl - najpopularniejszym narzędziem do internacjonalizacji (i18n) w Next.js App Router.
Dlaczego next-intl?
Next.js nie dostarcza wbudowanej biblioteki i18n dla App Router, a jedynie wzorce routingu. next-intl wypełnia tę lukę, oferując:
- Pełne wsparcie dla Server Components
- Type-safe tłumaczenia z TypeScript
- Formatowanie dat, liczb i walut
- Obsługa pluralizacji
- ICU Message Format
- Routing oparty na locale
Wdrożenie zawsze przebiega w tej kolejności: definiujesz obsługiwane języki, tworzysz pliki tłumaczeń, konfigurujesz middleware wykrywające język i dopiero wtedy wyświetlasz tłumaczenia w komponentach.
Instalacja i konfiguracja
Krok 1: Instalacja
Biblioteka to jeden pakiet, który działa zarówno w komponentach serwerowych, jak i klienckich:
1npm install next-intlPo instalacji nic się jeszcze nie zmienia, bo next-intl trzeba skonfigurować w kilku plikach.
Krok 2: Struktura plików tłumaczeń
Tłumaczenia trzymamy w katalogu messages, jeden plik JSON na każdy język (locale):
1messages/
2├── pl.json
3├── en.json
4└── de.jsonKlucze są pogrupowane w przestrzenie nazw, np. common czy products. Tak wygląda polski plik:
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}Wartości w klamrach to ICU Message Format. {price, number, ::currency/PLN} formatuje liczbę jako walutę, a plural wybiera formę zależną od liczby. Polski potrzebuje kategorii one, few i many, bo mówimy "1 sztuka", "3 sztuki", "5 sztuk".
Angielski plik ma te same klucze, ale prostszą pluralizację, bo angielski zna tylko one i 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}Klucze muszą być identyczne we wszystkich plikach, zmieniają się tylko wartości.
Krok 3: Konfiguracja next-intl
Najpierw jedno źródło prawdy o językach. as const zamienia tablicę w krotkę literałów, z której wyprowadzamy typ Locale:
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];Teraz TypeScript wie, że Locale to dokładnie 'pl' | 'en' | 'de'.
Plik i18n/request.ts mówi bibliotece, jakie tłumaczenia załadować dla danego żądania. Funkcja getRequestConfig dostaje requestLocale, czyli obietnicę z językiem odczytanym z adresu:
1// i18n/request.ts
2import { getRequestConfig } from 'next-intl/server';
3import { locales, defaultLocale } from './config';
4
5export default getRequestConfig(async ({ requestLocale }) => {
6 // Walidacja 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});Nieznany język zamieniamy na domyślny, a funkcja zwraca locale razem z komunikatami, strefą czasową i bieżącą datą. requestLocale działa w next-intl 4, choć dokumentacja najnowszych wydań oznacza go jako rozwiązanie starsze i w Next.js 16.3+ odczytuje język przez next/root-params.
Krok 4: Middleware
Middleware wykrywa język z adresu lub nagłówków przeglądarki i przekierowuje na właściwą ścieżkę. W Next.js 16 ten plik nazywa się proxy.ts:
1// middleware.ts (w 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', // lub 'always' lub 'never'
9});
10
11export const config = {
12 matcher: [
13 // Wszystkie ścieżki oprócz API, _next, plików statycznych
14 '/((?!api|_next|_vercel|.*\..*).*)',
15 ],
16};localePrefix: 'as-needed' ukrywa prefiks dla języka domyślnego, więc polska strona ma adres /products, a angielska /en/products. matcher pomija API, pliki Next.js i pliki statyczne.
Krok 5: Struktura folderów z [locale]
Wszystkie strony przenosimy do dynamicznego segmentu [locale], dzięki czemu język jest częścią adresu:
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.cssKatalog api zostaje poza segmentem, bo endpointy nie potrzebują wersji językowych.
Krok 6: Root Layout z locale
Layout w [locale] staje się głównym layoutem aplikacji. generateStaticParams generuje statyczne wersje dla wszystkich języków, a params jest od Next.js 15 obietnicą (w Next.js 16 nie da się go już odczytać synchronicznie):
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 // Walidacja 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}Nieobsługiwany język kończy się stroną 404. NextIntlClientProvider udostępnia tłumaczenia komponentom klienckim, a w next-intl 4 dziedziczy komunikaty automatycznie, więc prop messages jest opcjonalny.
Używanie tłumaczeń
W Server Components
W komponentach serwerowych używasz asynchronicznej funkcji getTranslations z nazwą przestrzeni:
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// Generowanie metadanych z tłumaczeniami
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}Funkcja t zwraca tekst dla klucza w wybranej przestrzeni. generateMetadata używa jej z jawnym locale, więc tytuł strony też jest przetłumaczony.
W Client Components
W komponentach klienckich ten sam interfejs daje hook useTranslations:
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}Drugi argument t przekazuje zmienne do komunikatu, więc { count: product.stock } wybierze właściwą formę liczby mnogiej. Komponent nie wie, jaki jest język, wie to provider.
Formatowanie
Daty i czas
Hook useFormatter formatuje daty według zasad bieżącego języka, korzystając z API Intl przeglądarki:
1import { useFormatter } from 'next-intl';
2
3function EventDate({ date }: { date: Date }) {
4 const format = useFormatter();
5
6 return (
7 <div>
8 {/* Pełna data */}
9 <p>{format.dateTime(date, { dateStyle: 'full' })}</p>
10
11 {/* Tylko data */}
12 <p>{format.dateTime(date, {
13 year: 'numeric',
14 month: 'long',
15 day: 'numeric'
16 })}</p>
17
18 {/* Czas względny */}
19 <p>{format.relativeTime(date)}</p>
20
21 {/* Zakres dat */}
22 <p>{format.dateTimeRange(startDate, endDate, {
23 dateStyle: 'medium'
24 })}</p>
25 </div>
26 );
27}Ten sam kod da "sobota, 26 września 2026" po polsku i "Saturday, September 26, 2026" po angielsku.
Liczby i waluty
Te same zasady dotyczą liczb, procentów, jednostek i list. Separator tysięcy, symbol waluty i spójnik w liście zależą od języka:
1import { useFormatter } from 'next-intl';
2
3function PriceDisplay({ amount }: { amount: number }) {
4 const format = useFormatter();
5
6 return (
7 <div>
8 {/* Waluta */}
9 <p>{format.number(amount, { style: 'currency', currency: 'PLN' })}</p>
10
11 {/* Procenty */}
12 <p>{format.number(0.25, { style: 'percent' })}</p>
13
14 {/* Jednostki */}
15 <p>{format.number(1500, {
16 style: 'unit',
17 unit: 'kilometer',
18 unitDisplay: 'long'
19 })}</p>
20
21 {/* Listy */}
22 <p>{format.list(['React', 'Next.js', 'TypeScript'], { type: 'conjunction' })}</p>
23 </div>
24 );
25}format.list wstawi "i" po polsku i "and" po angielsku, bez żadnego warunku w kodzie.
Przełącznik języków
Nawigacja świadoma języka pochodzi z funkcji createNavigation. Tworzysz ją raz, w osobnym pliku:
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});Te wersje Link, useRouter i usePathname znają listę języków i same dodają prefiks do adresu. Przełącznik korzysta z nich tak:
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 }) zostaje na tej samej stronie i zmienia tylko język. Stary import z next-intl/client nie istnieje w aktualnych wersjach biblioteki.
Linki z zachowaniem locale
Link z tego samego pliku automatycznie dopisuje prefiks aktualnego języka:
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}Prop locale pozwala wskazać inny język, np. w stopce z listą wersji językowych.
Type-safe tłumaczenia
Typ komunikatów możemy wyprowadzić z polskiego pliku JSON i zarejestrować w next-intl przez rozszerzenie interfejsu AppConfig:
1// types/messages.ts
2import pl from '../messages/pl.json';
3
4// Automatyczne typowanie z pliku JSON
5type Messages = typeof pl;
6
7declare module 'next-intl' {
8 interface AppConfig {
9 Messages: Messages;
10 }
11}Teraz TypeScript będzie podpowiadać dostępne klucze, a także sprawdzi, czy przekazujesz wymagane parametry komunikatu:
1const t = useTranslations('products');
2t('title'); // OK
3t('price', { price: 100 }); // OK z parametrem
4t('nonExistent'); // TypeScript error!Literówka w kluczu zatrzyma build, zamiast pokazać użytkownikowi surowy tekst products.nonExistent.
Praktyczny przykład - E-commerce
Strona produktów łączy tłumaczenie po stronie serwera z komponentem klienckim ProductCard z wcześniejszej sekcji:
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}Nagłówek tłumaczy serwer, a karty same tłumaczą swoje etykiety. Rozszerzony plik polski dodaje filtry, sortowanie i licznik wyników:
1// Rozszerzony 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}Zagnieżdżone obiekty, jak filters czy sort, odczytasz kluczem z kropką, np. t('filters.all').
Podsumowanie
next-intl oferuje kompletne rozwiązanie i18n dla Next.js:
- Server Components - pełne wsparcie z
getTranslations - Client Components - hooki
useTranslations,useFormatter - Type Safety - TypeScript podpowiada dostępne klucze
- Formatowanie - daty, liczby, waluty, listy
- Routing - automatyczne prefixy locale w URL
- Pluralizacja - poprawna odmiana dla każdego języka
Moja rada: od pierwszego dnia trzymaj teksty w plikach JSON, nawet jeśli dziś obsługujesz jeden język. W następnej lekcji zapakujesz aplikację w kontener Docker.
Zapamiętaj: w Metropolii Quantum komponent zna tylko klucze, a język, formy liczby mnogiej i format dat dobiera za niego next-intl.
Kod do tej lekcji: App.tsx
1import React, { useState } from 'react';
2
3// Stworz API route dla 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: Zaimplementuj simulateCreateSession
16 // Parsuj requestBody, waliduj, zwroc 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: Przycisk "Wyslij request" */}
28 </div>
29 <div>
30 <h3>Response</h3>
31 {/* TODO: Wyswietl response JSON */}
32 </div>
33 </div>
34 </div>
35 );
36}Widzisz błąd w tej lekcji?
Sprawdź się
Odpowiedz na pytania z tej lekcji. Wybierz odpowiedź, a od razu zobaczysz, czy jest poprawna.
1. next-intl w Next.js służy do:
2. Co zapewnia biblioteka next-intl w aplikacjach Next.js?
Zadania praktyczne w grze
- Edytor kodu
Zbuduj interfejs z wielojęzycznym wsparciem (i18n)
- Układanie w pionie
Ułóż kroki wdrożenia internacjonalizacji (i18n) w Next.js