9.0 KiB
i18n
Lokalizacja: konfiguracja locali dla Payload, negocjacja języka (cookie / Accept-Language / default), budowanie ścieżek locale-aware, przełączanie języka bez 404.
Config (payload.config.ts)
ipalKit({
i18n: {
defaultLocale: 'pl',
locales: [
{ code: 'pl', label: 'Polski' },
{ code: 'en', label: 'English' },
// { code: 'ar', label: 'العربية', rtl: true },
],
// fallback: true, // domyślnie true
},
})
Plugin ustawia config.localization z tego. Walidacja jest eager (fail-fast
przy starcie): pusta lista, duplikaty kodów, defaultLocale spoza listy,
zły format kodu → błąd [ipal] i18n: ....
Front — helpery
Wszystkie helpery są czyste (przyjmują config jako argument). Trzymaj swój
i18nConfig w jednym miejscu i importuj gdzie trzeba.
import {
getLocaleCodes, getDefaultLocale, isValidLocale, getLocaleDefinition,
negotiateLocale, buildLocalizedPath, switchLocalePath,
getLocalizedSlugs, LOCALE_COOKIE_NAME,
} from '@intecion/ipal-kit'
const config = { defaultLocale: 'pl', locales: [{code:'pl',label:'Polski'},{code:'en',label:'English'}] }
getLocaleCodes(config) // ['pl', 'en']
isValidLocale('de', config) // false
Budowanie ścieżek
// dokument pobrany z locale:'all' → slug to mapa { pl, en }
const doc = await payload.findByID({ collection: 'pages', id, locale: 'all' })
const slugs = getLocalizedSlugs({ slugField: doc.slug, config })
// { pl: 'o-nas', en: 'about' }
buildLocalizedPath({ slugs, locale: 'en', config }) // '/en/about'
// home slug ('home') zwija się do roota:
buildLocalizedPath({ slugs: { en: 'home' }, locale: 'en', config }) // '/en'
Pułapka typu (TypeScript): przy
locale: 'all'Payload w RUNTIME zwraca zlokalizowane pole jako obiekt{ pl, en }, ale wygenerowane typy Payloada deklarujądoc.slugjakostring(typ nie odróżnia trybuall).tsczgłosi więc niezgodność. Rozwiązanie — czyste rzutowanie na oczekiwany przez helper typ:const slugs = getLocalizedSlugs({ slugField: doc.slug as unknown as Record<string, unknown>, config, })To nie hack — to pomost między statycznym typem (string) a rzeczywistym kształtem runtime (obiekt), którego generator typów Payloada nie modeluje.
as unknown asjest tu poprawne, bo TS nie zna trybuall.
Przełącznik języka (bez 404)
// /pl/strona-glowna → klik EN → /en/home (albo /en jeśli brak tłumaczenia)
const href = switchLocalePath({ slugs, targetLocale: 'en', config })
router.push(href)
switchLocalePath nigdy nie zwraca undefined — brak slug w danym locale →
fallback na /{locale} (root), zamiast dead-endu na 404.
Middleware — patrz osobno
Negocjacja locale + redirect na wejściu (domena.com → /pl) jest w
@intecion/ipal-kit/next/middleware. Zobacz proxy w tej sekcji
niżej.
Proxy (dawniej middleware)
Next 16: plik nazywa się teraz
proxy.ts, funkcjaproxy. Import z pluginu (@intecion/ipal-kit/next/middleware) bez zmian — to nazwa subpath eksportu, niezależna od nazwy pliku projektu. Migracja:npx @next/codemod@canary middleware-to-proxy .
// proxy.ts (projekt klienta) — jedyna logika to podłączenie
import { NextResponse } from 'next/server'
import { createLocaleMiddleware, DEFAULT_MIDDLEWARE_MATCHER } from '@intecion/ipal-kit/next/middleware'
const i18nConfig = {
defaultLocale: 'pl',
locales: [{ code: 'pl', label: 'Polski' }, { code: 'en', label: 'English' }],
}
const localeMiddleware = createLocaleMiddleware({ config: i18nConfig })
export function proxy(req) {
const r = localeMiddleware(req)
if (r.type === 'next') return NextResponse.next()
const res = NextResponse.redirect(r.location)
if (r.cookie) res.cookies.set(r.cookie.name, r.cookie.value)
return res
}
export const config = { matcher: DEFAULT_MIDDLEWARE_MATCHER }
Zachowanie:
- ścieżka z locale (
/pl/...) → przepuść - root albo ścieżka bez locale (
/,/o-nas) → redirect na/{locale}..., locale z: cookie → Accept-Language → default - wybrany locale zapisany w cookie (
LOCALE_COOKIE_NAME)
DEFAULT_MIDDLEWARE_MATCHER wyklucza api, admin, _next, pliki statyczne.
Cookie locale a zgoda (RODO)
Wybór języka zapisywany jest w cookie NEXT_LOCALE (konwencja Next.js —
kompatybilna z innymi bibliotekami i18n, które czytają aktywny locale). Ale
zapis podlega zgodzie: to cookie kategorii functional, więc:
- Zapis TYLKO za zgodą. Middleware zapisuje
NEXT_LOCALEjedynie, gdy użytkownik zgodził się na kategorię functional (mayPersistLocalesprawdza zgodę). Bez zgody język działa per żądanie (negocjacja z Accept-Language), ale nie jest utrwalany. - Sprzątanie po cofnięciu zgody. Gdy użytkownik cofnie zgodę na functional,
cookie
NEXT_LOCALEjest usuwane automatycznie (consent zna tę cookie przezDEFAULT_COOKIE_MAP— patrz consent.md).
Nazwa cookie to jedna stała LOCALE_COOKIE_NAME (modules/i18n/negotiateLocale),
propagująca do middleware i sprzątania consent. Można nadpisać w
createLocaleMiddleware({ cookieName }), ale domyślnie NEXT_LOCALE jest
zalecane (interop).
Kolejność negocjacji locale
- Cookie
NEXT_LOCALE(jeśli jest — czyli był wybór za zgodą) - Nagłówek
Accept-Language(preferencje przeglądarki) defaultLocalez konfiguracji
Wejście na / → negocjacja → redirect na /pl (albo wynik negocjacji).
Zmiana języka (URL /en różny od cookie) → zapis nowego wyboru (za zgodą).
Migracja ze starej nazwy: wcześniej cookie nazywało się
ipal-locale. Po zmianie naNEXT_LOCALEużytkownicy ze starą cookie przejdą raz ponowną negocjację (stara cookie ignorowana). Jednorazowe, bez wpływu na nowe projekty.
Strona jednojęzyczna (bez prefiksu /pl)
Gdy projekt ma JEDEN język, adresy nie mają prefiksu locale: /o-nas, nie
/pl/o-nas. Plugin wykrywa to automatycznie — jeden locale w config = tryb
jednojęzyczny. Helpery (buildLocalizedPath, hreflang, middleware) dostosowują
się same:
- buildLocalizedPath →
/o-nas(bez/pl), home →/ - buildHreflangAlternates → pusto (jeden język = brak alternatyw językowych)
- localeMiddleware → pass-through (brak przekierowania
/→/pl, brak negocjacji) - canonical →
https://klient.pl/o-nas(bez prefiksu)
Config — jeden locale
// i18n.config.ts
export const i18nConfig = {
locales: [{ code: 'pl', label: 'Polski' }], // JEDEN locale
defaultLocale: 'pl',
}
Struktura katalogów — BEZ [locale]
To kluczowa różnica. Projekt jednojęzyczny NIE ma folderu [locale]:
# JEDNOJĘZYCZNY (bez [locale])
app/(frontend)/
layout.tsx # locale stałe z config, nie z params
not-found.tsx
[[...slug]]/page.tsx # /o-nas, /kontakt
# WIELOJĘZYCZNY (z [locale]) — dla porównania
app/(frontend)/[locale]/
layout.tsx # locale z params
[[...slug]]/page.tsx # /pl/o-nas, /en/about
Layout jednojęzyczny — locale z config
// app/(frontend)/layout.tsx (bez [locale])
import { i18nConfig } from '@/i18n.config'
export default async function Layout({ children }: { children: React.ReactNode }) {
const locale = i18nConfig.defaultLocale // stałe, nie z params
const settings = await getSettings(locale)
// ...reszta jak zwykle, ale locale jest stałe
return <html lang={locale}>...</html>
}
Strony jednojęzyczne — locale z config
// app/(frontend)/[[...slug]]/page.tsx (bez [locale])
import { i18nConfig } from '@/i18n.config'
export async function generateMetadata({ params }) {
const { slug } = await params // TYLKO slug, nie locale
const locale = i18nConfig.defaultLocale // stałe
return pageMetadata({ payload: await getCachedPayload(), locale, slug })
}
export default async function Page({ params }) {
const { slug } = await params
const locale = i18nConfig.defaultLocale // stałe
const route = await resolveRoute(locale, slug ?? [], pageNum)
// ...
}
resolveRoute i inne helpery działają bez zmian — dostają stałe locale z config
zamiast z URL. Cała różnica to: brak [locale] w strukturze, locale z config.
Middleware/proxy — jednojęzyczny prawie go nie potrzebuje
Dla jednego locale middleware jest pass-through (nic nie przekierowuje). Możesz go pominąć albo zostawić — plugin i tak wykryje 1 locale i przepuści. Bez przełącznika języka (jeden język), bez cookie NEXT_LOCALE (nie ma co pamiętać).
Przejście jedno- → wielojęzyczny (later)
Jeśli klient później doda drugi język, to PRZEBUDOWA, nie przełącznik:
- dodaj locale do config
- przenieś strukturę do
[locale]/ - layout/strony czytają locale z params
- wróci prefiks
/pl,/en+ hreflang
Warto to przewidzieć na starcie: jeśli jest szansa na drugi język, rozważ od razu
strukturę wielojęzyczną (z [locale]), nawet dla jednego locale — wtedy prefiks
/pl jest, ale dodanie języka to tylko config, nie przebudowa struktury.