Przejdź do treści

Wielojęzyczna strona w Next.js App Router (i18n)

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:

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

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

  3. 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.ts
export const locales = ['pl', 'en'] as const
export const defaultLocale = 'pl' as const
 
export type Locale = (typeof locales)[number]
Code
// i18n/request.ts
import { 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.ts
import { 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.ts
import 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ć:

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

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

  3. 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.tsx
import { 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.tsx
import { 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.

Tłumaczenia w Client Components

Code
'use client'
 
import { useTranslations } from 'next-intl'
 
export default function ContactForm() {
  const t = useTranslations('Contact')
 
  return (
    <div>
      <h2>{t('title')}</h2>
      <input placeholder={t('name')} />
      <input placeholder={t('email')} type="email" />
      <textarea placeholder={t('message')} />
      <button type="submit">{t('send')}</button>
    </div>
  )
}

Nawigacja między wersjami językowymi

Code
'use client'
 
import { useLocale } from 'next-intl'
import { usePathname, useRouter } from '@/i18n/routing'
import type { Locale } from '@/i18n/config'
 
export function LanguageSwitcher() {
  const locale = useLocale()
  const pathname = usePathname()
  const router = useRouter()
 
  function switchLocale(newLocale: Locale) {
    router.replace(pathname, { locale: newLocale })
  }
 
  return (
    <div className="flex gap-2">
      <button
        onClick={() => switchLocale('pl')}
        className={locale === 'pl' ? 'font-bold' : 'opacity-60'}
      >
        PL
      </button>
      <button
        onClick={() => switchLocale('en')}
        className={locale === 'en' ? 'font-bold' : 'opacity-60'}
      >
        EN
      </button>
    </div>
  )
}

SEO wielojęzycznej strony w Next.js

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.tsx
import type { Metadata } from 'next'
 
const baseUrl = 'https://example.com'
 
// przy 'as-needed' domyślny język (pl) nie ma prefiksu w URL
const 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,
      },
    },
  }
}

Wynikowy HTML dla /kontakt:

Code
<link rel="canonical" href="https://example.com/kontakt" />
<link rel="alternate" hreflang="pl" href="https://example.com/kontakt" />
<link rel="alternate" hreflang="en" href="https://example.com/en/kontakt" />
<link rel="alternate" hreflang="x-default" href="https://example.com/kontakt" />

Sitemap wielojęzyczna w Next.js

Code
// app/sitemap.ts
import { 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 URL
function 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:

Code
// i18n/routing.ts
export const routing = defineRouting({
  locales,
  defaultLocale,
  localePrefix: 'as-needed',
  pathnames: {
    '/': '/',
    '/uslugi': {
      pl: '/uslugi',
      en: '/services',
    },
    '/kontakt': {
      pl: '/kontakt',
      en: '/contact',
    },
    '/blog/[slug]': {
      pl: '/blog/[slug]',
      en: '/blog/[slug]',
    },
  },
})
Audyt techniczny i optymalizacja pod kątem SEO i GEO.
Audyt techniczny SEO

Często zadawane pytania

Czy i18n w App Router działa z SSG?

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.

Pomagam przekładać takie tematy na konkretne wdrożenia w frontendzie, SEO, analityce i procesie produktowym.

Skontaktuj się ze mną

Biblioteka wiedzy na temat Next.js

Czytaj dalej

Zobacz więcej wpisów
Wielojęzyczność i hreflang w Astro SSG: Zasięg globalny, wdrożenie lokalne

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

Maciej Sala

Founder StriveLab

App Router czy Pages Router — co wybrać?

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

Maciej Sala

Founder StriveLab

Middleware w Next.js — 7 zastosowań i typowych pułapek

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.

Maciej Sala

Maciej Sala

Founder StriveLab