Kurs Next.js · Moduł 9: Integracje i zaawansowane funkcje

Internacjonalizacja z next-intl

10 min czytania
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-intl

Po 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.json

Klucze 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.css

Katalog 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. 1. next-intl w Next.js służy do:

  2. 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

Przydatne artykuły