webroad.online
  1. 1Web
  2. 2HTML
  3. 3CSS
  4. 4JavaScript
  5. 5TypeScript
  6. 6Git
  7. 7Tooling
  8. 8React
  9. 9State management
  10. 10Next.js
  11. 11Forms
  12. 12Data and backend
  13. 13SEO
  14. 14Tailwind CSS
  15. 15Animations
  16. 16Testing
  17. 17Architecture
Next.js · Lesson 12 of 13

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 = ro

The 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 one
    • request.tsgetRequestConfig: the current locale and its messages
    • navigation.tscreateNavigation: Link, redirect, usePathname
  • messages/
    • en.jsonthe text, grouped into namespaces
    • ro.json
    • ru.json
  • app/
    • [locale]/
      • layout.tsxhtml lang, NextIntlClientProvider
      • page.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 ago

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

// 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, through alternates.languages; next-intl also sends a link header with the same information.
  • <html lang={locale}>, not a hardcoded lang="en".
  • Don't auto-redirect prefixed addresses based on Accept-Language or 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 in request.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>.
  • useTranslations in components, getTranslations in async server code and generateMetadata, one NextIntlClientProvider in the layout.
  • ICU messages for variables and plurals, Intl for dates and money, never glued-together strings.

Official sources

Exercises

Was this page helpful?

One tap — no account needed.