Proper localization in Next.js with next-intl
[locale] routes, next-intl, ICU messages, formatting with Intl and hreflang.
Updated
i18n and l10n
- i18n (internationalization: 18 letters between the "i" and the "n") means preparing the code for many languages: text lives in message files instead of JSX, and dates and prices go through
Intl. - l10n (localization) means adding one specific language: translating the messages, picking the date format, the currency, the plural forms.
You do i18n once. You do l10n for every new language, usually without touching the code.
Where the locale lives
| Strategy | Example | For SEO |
|---|---|---|
| URL prefix | example.com/ro/preturi |
best: every language has its own URL, easy to index and to connect with hreflang |
| domain | example.ro, example.de |
good, but you pay for and maintain several domains, and authority is split between them |
| cookie only | example.com/pricing |
bad: the crawler sends no cookies, so it only ever sees one language |
The prefix is the default choice. The proxy picks the language only for addresses without a prefix:
GET /pricingproxy.ts→Accept-Languagero-RO,ro;q=0.9,en;q=0.7redirect→/ro/pricingapp/[locale] with locale = roThe setup files
next-intl (v4) is the most widely used library with the App Router. A project looks like this:
i18n/routing.tsdefineRouting: the locales and the default onerequest.tsgetRequestConfig: the current locale and its messagesnavigation.tscreateNavigation: Link, redirect, usePathname
messages/en.jsonthe text, grouped into namespacesro.jsonru.json
app/[locale]/layout.tsxhtml lang, NextIntlClientProviderpage.tsx
proxy.tscreateMiddleware(routing)next.config.tscreateNextIntlPlugin() wires up 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() // the [locale] segment
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|.*\\..*).*)' }The locale layout
// app/[locale]/layout.tsx
export function generateStaticParams() {
return routing.locales.map(locale => ({ locale })) // /en, /ro, /ru built at build time
}
export default async function LocaleLayout({ children, params }: LayoutProps<'/[locale]'>) {
const { locale } = await params
if (!hasLocale(routing.locales, locale)) notFound() // /xx → 404, not a crash
return (
<html lang={locale}>
<body>
<NextIntlClientProvider>{children}</NextIntlClientProvider>
</body>
</html>
)
}setRequestLocale. For static rendering you used to call setRequestLocale(locale) in every layout and page. The next-intl docs now say that if request.ts reads the locale from next/root-params (available by default since Next 16.3), you no longer need it, and the integration with cacheComponents is better too. generateStaticParams is still required. On older Next versions, setRequestLocale is still needed.
Translating on the server and the client
// Server or Client Component (not async)
const t = useTranslations('Cart')
return <button>{t('add')}</button>
// async server code: actions, 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') }
}ICU messages
{
"greeting": "Hi, {name}!",
"items": "{count, plural, =0 {Your cart is empty} one {# item} other {# items}}",
"invited": "{gender, select, female {She invited you} male {He invited you} other {They invited you}}",
"terms": "I accept the <link>terms</link>."
}t('greeting', { name })
t('items', { count: 3 }) // "3 items"
t.rich('terms', { link: chunks => <Link href="/terms">{chunks}</Link> })Every language has its own plural forms: English has 2 (one, other), Romanian 3 (one, few, other), Russian 4 (one, few, many, other): 1 товар, 2 товара, 5 товаров, 21 товар. The translator writes all of them in the message, and the code stays the same.
Dates, numbers, relative time
const format = useFormatter() // in async server code: await getFormatter()
format.dateTime(post.date, { dateStyle: 'long' }) // October 9, 2026
format.number(price, { style: 'currency', currency: 'EUR' }) // €1,234.50
format.relativeTime(post.updatedAt, now) // 3 days agoType-safe messages
// global.ts
declare module 'next-intl' {
interface AppConfig {
Locale: (typeof routing.locales)[number]
Messages: typeof messages // import messages from './messages/en.json'
}
}Now t('Cart.ad') is a TypeScript error, and you get autocomplete on keys.
Localized links
// i18n/navigation.ts
export const { Link, redirect, usePathname, useRouter } = createNavigation(routing)<Link href="/pricing"> goes to /ro/pricing when you're on /ro, and <Link href={usePathname()} locale="ru"> switches the language on the same page. With pathnames in defineRouting you can translate the URL too: '/pricing': { en: '/pricing', ro: '/preturi', ru: '/ceny' }.
Multilingual SEO
- One language = one URL. Never serve two languages at the same address.
- hreflang on every version, reciprocal, plus
x-default. In Next, throughalternates.languages; next-intl also sends alinkheader with the same information. <html lang={locale}>, not a hardcodedlang="en".- Don't auto-redirect prefixed addresses based on
Accept-Languageor IP. Googlebot crawls mostly from the US, so it would only ever see English. A redirect is fine only on/.
alternates: {
canonical: `/${locale}/pricing`,
languages: { en: '/en/pricing', ro: '/ro/pricing', ru: '/ru/pricing', 'x-default': '/en/pricing' },
}The rest is in Technical SEO and Metadata and SEO.
Organizing messages
- Namespaces by feature:
Cart,Checkout,Pricing, not one flat level with 500 keys. - One file per language is fine at first. As it grows, split it by feature (
messages/ro/cart.json) and merge them inrequest.ts. - Workflow: the developer adds the key in the source language, a script or a translation service shows what's missing in the others, and CI fails when a key is missing.
Common mistakes
| Mistake | Correct |
|---|---|
| text written straight into JSX | every visible string comes from t() |
t('hello') + ' ' + name |
t('greeting', { name }), with the variable in the message |
next/link with href="/pricing" |
Link from i18n/navigation, which keeps the locale |
| the provider ships every message to the client | translate in Server Components or give the provider only the namespaces it needs |
count === 1 ? 'item' : 'items' |
{count, plural, ...} or Intl.PluralRules |
What about no library?
A small site can do i18n with Next alone: a [locale] segment, proxy.ts for the redirect from /, JSON dictionaries loaded on the server and Intl for formatting. That's exactly how webroad.online is built. next-intl pays off when you have lots of text in Client Components, plurals and ICU messages, translated URLs, or a team that wants keys checked by TypeScript.
In short
- The locale lives in the URL (
/ro/...), the proxy picks a language only for/, and every version has reciprocal hreflang and a correct<html lang>. useTranslationsin components,getTranslationsin async server code andgenerateMetadata, oneNextIntlClientProviderin the layout.- ICU messages for variables and plurals,
Intlfor dates and money, never glued-together strings.