Zbuduj wielojęzyczną stronę w Next.js. Zobacz, jak skonfigurować routing językowy, ograć pułapki SEO, hreflang i statyczne generowanie stron.
Maciej Sala
Founder StriveLab
6 min czytaniaOpublikowano 10 kwietnia 2026 (Aktualizacja 7 lipca 2026)
Dlaczego i18n w Next.js App Router wygląda inaczej?
Wprowadzone zmiany są dobrze przemyślane przez zespół Next.js, ponieważ choć App Router wymaga więcej pracy przy starcie, ostatecznie daje większą elastyczność. W tym artykule pokazuję najpopularniejsze podejście, czyli , bibliotekę, która integruje się z App Routerem, Server Components i .
Architektura i18n w Next.js App Router
Wielojęzyczna strona w Next.js App Router opiera się na trzech filarach:
Routing z prefiksem językowym /pl/kontakt, /en/contact. Oznacza, że każda wersja językowa strony ma swój unikalny adres URL, który zawiera prefiks językowy. Przykładowo, strona kontaktowa w języku polskim będzie dostępna pod adresem /pl/kontakt, a jej angielska wersja pod /en/contact. W Next.js App Router osiąga się to poprzez dynamiczny segment językowy w strukturze katalogów, np. app/[locale]/. Takie podejście zapewnia jasność dla użytkowników i wyszukiwarek, a także ułatwia zarządzanie treścią dla różnych języków.
Proxy (dawniej middleware). Wykrywa język użytkownika i przekierowuje Proxy (w Next.js 16 nazywane proxy.ts, w starszych wersjach middleware.ts) to funkcja, która wykonuje się przed każdym żądaniem do aplikacji. Jej głównym zadaniem w kontekście i18n jest wykrycie preferowanego języka użytkownika. Może to robić na podstawie nagłówka Accept-Language wysyłanego przez przeglądarkę, wcześniej ustawionego ciasteczka NEXT_LOCALE lub innych mechanizmów. Po wykryciu języka, proxy może przekierować użytkownika do odpowiedniej wersji językowej strony (np. z /kontakt na /en/kontakt dla użytkownika anglojęzycznego, jeśli domyślny język nie ma prefiksu) lub wewnętrznie przepisać ścieżkę żądania, aby aplikacja mogła obsłużyć treść w wybranym języku. Jest to kluczowy element zapewniający płynne doświadczenie użytkownika i prawidłowe kierowanie ruchu.
Tłumaczenia. Pliki JSON z tekstami w każdym języku Ten element odnosi się do sposobu przechowywania i zarządzania rzeczywistymi tekstami w różnych językach. Najczęściej stosuje się pliki JSON, gdzie każdy plik odpowiada za jeden język (np. pl.json, en.json). W tych plikach teksty są zorganizowane w hierarchiczną strukturę klucz-wartość, co ułatwia ich odwoływanie się w kodzie. Biblioteki takie jak next-intl integrują się z tym modelem, umożliwiając łatwe ładowanie odpowiednich tłumaczeń w zależności od aktywnego języka. Tłumaczenia są następnie dostępne w komponentach React, zarówno Server Components (za pomocą funkcji getTranslations), jak i Client Components (za pomocą hooka useTranslations), co pozwala na dynamiczne wyświetlanie treści w wybranym języku.
Code
app/
├── [locale]/ ← dynamiczny segment językowy
│ ├── layout.tsx
│ ├── page.tsx ← strona główna (/ oraz /en)
│ ├── kontakt/
│ │ └── page.tsx
│ └── blog/
│ └── [slug]/
│ └── page.tsx
proxy.ts ← w rootcie projektu, obok app/ (nie w środku!)
messages/
├── pl.json
└── en.json
Pamiętaj o jednym istotnym detalu: proxy.ts musi leżeć na tym samym poziomie co app/ — w rootcie projektu, a przy strukturze src/ wewnątrz src/. Plik umieszczony w nieodpowdnik miejscu spowoduje, że build przejdzie bez błędu, a routing językowy po prostu nie będzie działać. Może ta informacja zaoszczędzi komuś debuggowania.
Krok 1: instalacja i konfiguracja next-intl
Code
npm install next-intl
Pliki tłumaczeń dla next-intl
Code
// messages/pl.json{ "HomePage": { "title": "Tworzę strony internetowe w Next.js", "subtitle": "Szybkie, zoptymalizowane pod SEO, dopasowane do Twojego biznesu", "cta": "Sprawdź ofertę" }, "Navigation": { "home": "Strona główna", "services": "Usługi", "blog": "Blog", "contact": "Kontakt" }, "Contact": { "title": "Skontaktuj się", "name": "Imię i nazwisko", "email": "Adres e-mail", "message": "Wiadomość", "send": "Wyślij wiadomość" }}
Code
// messages/en.json{ "HomePage": { "title": "I build websites with Next.js", "subtitle": "Fast, SEO-optimized, tailored to your business", "cta": "View services" }, "Navigation": { "home": "Home", "services": "Services", "blog": "Blog", "contact": "Contact" }, "Contact": { "title": "Get in touch", "name": "Full name", "email": "Email address", "message": "Message", "send": "Send message" }}
Konfiguracja next-intl w App Router
Code
// i18n/config.tsexport const locales = ['pl', 'en'] as constexport const defaultLocale = 'pl' as constexport type Locale = (typeof locales)[number]
Code
// i18n/request.tsimport { getRequestConfig } from 'next-intl/server'import { routing } from './routing'export default getRequestConfig(async ({ requestLocale }) => { let locale = await requestLocale if (!locale || !routing.locales.includes(locale as any)) { locale = routing.defaultLocale } return { locale, messages: (await import(`../messages/${locale}.json`)).default, }})
Code
// i18n/routing.tsimport { defineRouting } from 'next-intl/routing'import { createNavigation } from 'next-intl/navigation'import { locales, defaultLocale } from './config'export const routing = defineRouting({ locales, defaultLocale, localePrefix: 'as-needed', // /pl nie ma prefixu, /en/page ma})export const { Link, redirect, usePathname, useRouter } = createNavigation(routing)
Krok 2: Proxy w Next.js do wykrywania języka
Code
// proxy.tsimport createMiddleware from 'next-intl/middleware'import { routing } from './i18n/routing'export default createMiddleware(routing)export const config = { // Wszystkie ścieżki poza API, wewnętrznymi Next.js i plikami statycznymi. // Matcher musi łapać też adresy bez prefiksu (np. /kontakt), bo przy // 'as-needed' to proxy rewrite'uje je do segmentu [locale]. matcher: ['/((?!api|_next|_vercel|.*\\..*).*)'],}
Proxy automatycznie wykrywa kilka rzeczy, o czym warto pamiętać:
Peferowany język z nagłówka Accept-Language. Mechanizm ten analizuje nagłówek Accept-Language wysyłany przez przeglądarkę użytkownika, aby określić jego preferowany język. Może również brać pod uwagę inne czynniki, takie jak wcześniej ustawione ciasteczka, co pozwala na bardziej spersonalizowane doświadczenie.
Przy localePrefix: 'as-needed' domyślny język zostaje bez prefiksu. Oznacza to, że dla użytkowników preferujących język domyślny (np. polski), adres URL pozostaje czysty (np. /kontakt). Proxy wewnętrznie przepisuje to żądanie do odpowiedniego segmentu [locale], zachowując estetykę URL. Natomiast użytkownicy innych języków (np. angielskiego) są automatycznie przekierowywani na adres z prefiksem językowym (np. /en/kontakt), a to zapewnia spójność i jasność nawigacji.
Ustawia cookie NEXT_LOCALE do zapamiętania wyboru. Po wykryciu lub wyborze języka, proxy zapisuje tę informację w ciasteczku NEXT_LOCALE. Takie rozwiązanie, sprawia, że przy kolejnych wizytach użytkownik automatycznie trafia na preferowaną wersję językową strony, a to znacząco poprawia komfort użytkowania.
Krok 3: layout z providerem tłumaczeń
Code
// app/[locale]/layout.tsximport { NextIntlClientProvider } from 'next-intl'import { getMessages, setRequestLocale } from 'next-intl/server'import { notFound } from 'next/navigation'import { locales } from '@/i18n/config'export function generateStaticParams() { return locales.map((locale) => ({ locale }))}export default async function LocaleLayout({ children, params,}: { children: React.ReactNode params: Promise<{ locale: string }>}) { const { locale } = await params if (!locales.includes(locale as any)) { notFound() } setRequestLocale(locale) const messages = await getMessages() return ( <html lang={locale}> <body> <NextIntlClientProvider messages={messages}> {children} </NextIntlClientProvider> </body> </html> )}
Krok 4: tłumaczenia w Server i Client Components
Tłumaczenia w Server Components
Należy zwrócić uwagę na pewien problem, mianowicie hook useTranslations działa wyłącznie w komponentach synchronicznych. Z kolei w asynchronicznych, Server Components, takich jak strony (page.tsx) lub layouty (layout.tsx), które często pobierają dane za pomocą await params, użycie hooków Reacta jest zabronione. W takich przypadkach należy używać funkcji getTranslations importowanej z next-intl/server, która pozwala na pobranie tłumaczeń bezpośrednio na serwerze:
Code
// app/[locale]/page.tsximport { getTranslations, setRequestLocale } from 'next-intl/server'import { Link } from '@/i18n/routing'export default async function HomePage({ params,}: { params: Promise<{ locale: string }>}) { const { locale } = await params setRequestLocale(locale) const t = await getTranslations('HomePage') return ( <main> <h1>{t('title')}</h1> <p>{t('subtitle')}</p> <Link href="/uslugi">{t('cta')}</Link> </main> )}
Link buduję komponentem Link z @/i18n/routing, a nie zwykłym <a>. Dzięki temu adres automatycznie dostaje właściwy prefiks językowy (np. /pl/ lub /en/) oraz, jeśli jest to skonfigurowane, tłumaczenie ścieżki. Takie podejście upraszcza logikę nawigacji, zapewnia spójność adresów URL w różnych językach i jest korzystne dla SEO.
informuje Google, która wersja językowa jest
przeznaczona dla danego regionu. Nie wolno jednak złamać dwóch zasad: hreflang
jest per-URL (każda podstrona wskazuje własne odpowiedniki językowe, na nie
stronę główną), więc definiujesz go w generateMetadata konkretnej strony, a
nie raz w layoucie. Po drugie, adresy muszą odpowiadać realnym URL-om — przy
localePrefix: 'as-needed' polska wersja funkcjonuje bez prefiksu /pl:
Code
// app/[locale]/kontakt/page.tsximport type { Metadata } from 'next'const baseUrl = 'https://example.com'// przy 'as-needed' domyślny język (pl) nie ma prefiksu w URLconst urls = { pl: `${baseUrl}/kontakt`, en: `${baseUrl}/en/kontakt`,}export async function generateMetadata({ params,}: { params: Promise<{ locale: string }>}): Promise<Metadata> { const { locale } = await params return { alternates: { canonical: urls[locale as keyof typeof urls], languages: { pl: urls.pl, en: urls.en, 'x-default': urls.pl, }, }, }}
// app/sitemap.tsimport { locales, defaultLocale } from '@/i18n/config'import type { MetadataRoute } from 'next'const baseUrl = 'https://example.com'// przy 'as-needed' domyślny język nie ma prefiksu w URLfunction urlFor(locale: string, page: string) { return locale === defaultLocale ? `${baseUrl}${page || '/'}` : `${baseUrl}/${locale}${page}`}export default function sitemap(): MetadataRoute.Sitemap { const pages = ['', '/uslugi', '/blog', '/kontakt'] return pages.flatMap((page) => locales.map((locale) => ({ url: urlFor(locale, page), lastModified: new Date(), alternates: { languages: Object.fromEntries(locales.map((l) => [l, urlFor(l, page)])), }, })), )}
Tłumaczenie ścieżek URL w Next.js
Jeśli chcesz, żeby adresy URL były tłumaczone (np. /pl/kontakt zamiast /en/contact), next-intl oferuje mechanizm mapowania ścieżek (pathnames). Jest to korzystne z kilku powodów i od razu mogę to zarekomendować. Po pierwsze, poprawia to SEO, ponieważ wyszukiwarki lepiej rozumieją strukturę strony i mogą indeksować treści z odpowiednimi słowami kluczowymi w danym języku. Druga kwestia jest równie istotna, ponieważ to rozwiązanie zwiększa czytelność i zaufanie użytkowników, którzy widzą adresy URL w swoim ojczystym języku, co przekłada się na lepsze doświadczenie:
Tak. Dzięki generateStaticParams Next.js generuje statyczne strony dla każdego języka w build time. Upewnij się, że wywołujesz setRequestLocale() w każdym page i layout, inaczej renderowanie wpadnie w tryb dynamiczny.
Ile języków mogę dodać?
Nie ma technicznego limitu, ponieważ każdy język to dodatkowy zestaw statycznych stron i plik tłumaczeń. Przy 10+ językach rozważ leniwe ładowanie tłumaczeń, żeby nie wysyłać do klienta wszystkich naraz.
Czy next-intl jest jedyną opcją?
Nie. Alternatywy to react-i18next (bardziej ręczna konfiguracja), paraglide (type-safe, kompilowane tłumaczenia) i ręczna implementacja z plikami JSON. next-intl jest jednak najbardziej zintegrowana z App Routerem i Server Components, dlatego to domyślny wybór.
Dlaczego App Router nie ma wbudowanego i18n jak Pages Router?
To świadoma decyzja zespołu Next.js. App Router daje większą elastyczność (tłumaczenie ścieżek, Server Components, własna logika detekcji), ale wymaga więcej pracy na starcie — stąd biblioteki takie jak next-intl, które tę pracę domykają.
Jak zadbać o SEO wielojęzycznej strony?
Każda wersja językowa musi mieć tagi hreflang (w metadata) wskazujące swoje odpowiedniki oraz x-default, a sitemap powinna zawierać wszystkie wersje. Bez tego Google traktuje tłumaczenia jak duplikaty, a jeśli pozwalamu mu na zgadywanie, którą wersję pokazać to będzie robił to często błędnie.
O autorze
Maciej Sala
Maciej Sala — Product Manager i Frontend Developer z bogatym doświadczeniem w marketingu internetowym oraz SEO. Na co dzień pracuje z Reactem, Next.js i TypeScriptem, a ostatnio także z Astro i narzędziami do automatyzacji procesów AI. Sprawnie łączy perspektywę produktową z praktycznym podejściem do kodu. Przez kilka lat był związany z branżą gier wideo jako project manager i game designer. Absolwent historii na Uniwersytecie Jagiellońskim oraz studiów podyplomowych z marketingu internetowego na AGH w Krakowie. Po godzinach trenuje na siłowni, maluje figurki i rozwijam własne projekty.
Zanim Twoja oferta dotrze do zagranicznych klientów, Twoja strona musi zdać egzamin techniczny. Większość witryn odpada na tym etapie, marnując potencjał nawet najlepszej treści. Sprawdź, jak rygorystycznie skonfigurować wielojęzyczność i hreflang na statycznym Astro, by zasięg globalny szedł w parze z lokalną, błyskawiczną dostawą treści.
Maciej Sala
Founder StriveLab
Next.js 13 wprowadził App Router , czyli nowy sposób budowania aplikacji oparty na React Server Components . Ale Pages Router nigdzie nie zniknął. Dwa routery, dwa podejścia, jedna decyzja do podjęcia na starcie projektu.
Maciej Sala
Founder StriveLab
Większość deweloperów może kojarzyć middleware w Next.js ze sprawdzaniem, czy użytkownik jest zalogowany, podczas gdy dla żądań objętych matcherem może ono podjąć decyzję jeszcze przed routingiem: przypisać wariant eksperymentu, zastosować limit, wykonać rewrite albo dodać zależny od requestu nagłówek. W artykule chciałem omówić siedem optymalnych i bezpiecznych zastosowań Proxy.