Localizare corectă în Next.js cu next-intl
Rute cu [locale], next-intl, mesaje ICU, formatare cu Intl și hreflang.
Actualizat
i18n și l10n
- i18n (internationalization: 18 litere între „i” și „n”) = pregătești codul pentru mai multe limbi: textele stau în fișiere de mesaje, nu în JSX, iar datele și prețurile trec prin
Intl. - l10n (localization) = adaugi o limbă anume: traduci mesajele, alegi formatul datei, moneda, pluralele.
Faci i18n o dată. l10n faci pentru fiecare limbă nouă, de obicei fără să mai atingi codul.
Unde stă limba
| Strategie | Exemplu | Pentru SEO |
|---|---|---|
| prefix în URL | example.com/ro/preturi |
cea mai bună: fiecare limbă are URL-ul ei, ușor de indexat și de legat cu hreflang |
| domeniu | example.ro, example.de |
bună, dar plătești și întreții mai multe domenii, iar autoritatea se împarte între ele |
| doar cookie | example.com/preturi |
proastă: crawler-ul nu trimite cookie-uri, deci vede o singură limbă |
Prefixul e alegerea implicită. Proxy-ul decide limba doar pentru adresele fără prefix:
GET /pricingproxy.ts→Accept-Languagero-RO,ro;q=0.9,en;q=0.7redirect→/ro/pricingapp/[locale] cu locale = roFișierele de setup
next-intl (v4) e biblioteca cea mai folosită cu App Router. Un proiect arată așa:
i18n/routing.tsdefineRouting: limbile și limba implicitărequest.tsgetRequestConfig: limba curentă și mesajele einavigation.tscreateNavigation: Link, redirect, usePathname
messages/en.jsontextele, grupate pe namespace-uriro.jsonru.json
app/[locale]/layout.tsxhtml lang, NextIntlClientProviderpage.tsx
proxy.tscreateMiddleware(routing)next.config.tscreateNextIntlPlugin() leagă request.ts
// i18n/routing.ts
export const routing = defineRouting({ locales: ['en', 'ro', 'ru'], defaultLocale: 'en' })
// i18n/request.ts
import * as rootParams from 'next/root-params'
export default getRequestConfig(async () => {
const locale = await rootParams.locale() // segmentul [locale]
if (!hasLocale(routing.locales, locale)) notFound()
return { locale, messages: (await import(`../messages/${locale}.json`)).default }
})
// proxy.ts
export default createMiddleware(routing)
export const config = { matcher: '/((?!api|_next|_vercel|.*\\..*).*)' }Layout-ul de limbă
// app/[locale]/layout.tsx
export function generateStaticParams() {
return routing.locales.map(locale => ({ locale })) // /en, /ro, /ru generate la build
}
export default async function LocaleLayout({ children, params }: LayoutProps<'/[locale]'>) {
const { locale } = await params
if (!hasLocale(routing.locales, locale)) notFound() // /xx → 404, nu eroare
return (
<html lang={locale}>
<body>
<NextIntlClientProvider>{children}</NextIntlClientProvider>
</body>
</html>
)
}setRequestLocale. Înainte, pentru randare statică, chemai setRequestLocale(locale) în fiecare layout și pagină. Documentația next-intl spune acum că, dacă request.ts citește limba din next/root-params (inclus implicit din Next 16.3), nu mai e nevoie de el, iar integrarea cu cacheComponents e mai bună. generateStaticParams rămâne obligatoriu. Pe versiuni mai vechi de Next, setRequestLocale e încă necesar.
Traduceri pe server și pe client
// Server sau Client Component (fără async)
const t = useTranslations('Cart')
return <button>{t('add')}</button>
// cod async de server: acțiuni, route handlers, generateMetadata
export async function generateMetadata({ params }: PageProps<'/[locale]/pricing'>) {
const { locale } = await params
const t = await getTranslations({ locale, namespace: 'Pricing' })
return { title: t('title') }
}Mesaje ICU
{
"greeting": "Salut, {name}!",
"items": "{count, plural, =0 {Coșul e gol} one {# produs} few {# produse} other {# de produse}}",
"invited": "{gender, select, female {Ea te-a invitat} male {El te-a invitat} other {Te-a invitat}}",
"terms": "Accept <link>termenii</link>."
}t('greeting', { name })
t('items', { count: 3 }) // „3 produse”
t.rich('terms', { link: chunks => <Link href="/terms">{chunks}</Link> })Fiecare limbă își are propriile forme de plural: engleza are 2 (one, other), româna 3 (one, few, other), rusa 4 (one, few, many, other): 1 товар, 2 товара, 5 товаров, 21 товар. Traducătorul le scrie pe toate în mesaj, iar codul rămâne același.
Date, numere, timp relativ
const format = useFormatter() // pe server async: await getFormatter()
format.dateTime(post.date, { dateStyle: 'long' }) // 9 octombrie 2026
format.number(price, { style: 'currency', currency: 'EUR' }) // 1.234,50 €
format.relativeTime(post.updatedAt, now) // acum 3 zileMesaje type-safe
// global.ts
declare module 'next-intl' {
interface AppConfig {
Locale: (typeof routing.locales)[number]
Messages: typeof messages // import messages from './messages/en.json'
}
}Acum t('Cart.ad') e eroare de TypeScript, cu autocomplete pe chei.
Link-uri localizate
// i18n/navigation.ts
export const { Link, redirect, usePathname, useRouter } = createNavigation(routing)<Link href="/pricing"> duce la /ro/pricing dacă ești pe /ro, iar <Link href={usePathname()} locale="ru"> schimbă limba pe aceeași pagină. Cu pathnames în defineRouting poți traduce și URL-ul: '/pricing': { en: '/pricing', ro: '/preturi', ru: '/ceny' }.
SEO multilingv
- O limbă = un URL. Nu servi două limbi la aceeași adresă.
- hreflang pe fiecare versiune, reciproc, plus
x-default. În Next, prinalternates.languages; next-intl trimite și un headerlinkcu aceleași informații. <html lang={locale}>, nu unlang="en"fix.- Nu redirecționa automat după
Accept-Languagesau IP adresele cu prefix. Googlebot crawlează mai ales din SUA, deci ar vedea doar engleza. Redirectul e ok doar pe/.
alternates: {
canonical: `/${locale}/pricing`,
languages: { en: '/en/pricing', ro: '/ro/pricing', ru: '/ru/pricing', 'x-default': '/en/pricing' },
}Restul e în SEO tehnic și Metadata și SEO.
Organizarea mesajelor
- Namespace-uri pe funcționalitate:
Cart,Checkout,Pricing, nu un singur nivel cu 500 de chei. - Un fișier per limbă e ok la început. Când crește, îl împarți pe funcționalități (
messages/ro/cart.json) și le unești înrequest.ts. - Flux: dezvoltatorul adaugă cheia în limba sursă, un script sau un serviciu de traduceri arată ce lipsește în celelalte, iar CI-ul pică dacă o cheie lipsește.
Greșeli frecvente
| Greșeală | Corect |
|---|---|
| text scris direct în JSX | orice text vizibil vine din t() |
t('hello') + ' ' + name |
t('greeting', { name }), cu variabila în mesaj |
next/link cu href="/pricing" |
Link din i18n/navigation, păstrează limba |
| provider-ul trimite toate mesajele la client | traduci în Server Components sau dai providerului doar namespace-urile necesare |
count === 1 ? 'produs' : 'produse' |
{count, plural, ...} sau Intl.PluralRules |
Și fără bibliotecă?
Un site mic poate face i18n doar cu Next: un segment [locale], proxy.ts pentru redirectul de pe /, dicționare JSON încărcate pe server și Intl pentru formatare. Chiar așa e făcut și webroad.online. next-intl merită când ai multe texte în Client Components, plurale și mesaje ICU, URL-uri traduse sau o echipă care vrea chei verificate de TypeScript.
Pe scurt
- Limba stă în URL (
/ro/...), proxy-ul alege limba doar pentru/, iar fiecare versiune are hreflang reciproc și<html lang>corect. useTranslationsîn componente,getTranslationsîn cod async de server și îngenerateMetadata, un singurNextIntlClientProviderîn layout.- Mesaje ICU pentru variabile și plurale,
Intlpentru date și bani, niciodată stringuri lipite.