Home/Learn/Next.js/Internationalisation

Internationalisation

Advanced
UX & Assets

A locale segment, a dictionary loaded on the server, and middleware to pick a default. The routing is the easy part; deciding what actually needs translating is not.

Overview

The App Router has no built-in i18n routing — the Pages Router feature was removed — so the convention is a `[locale]` segment and middleware that redirects a bare URL to a default. Doing it on the server has a real advantage over client-side i18n libraries: only the active locale's dictionary is sent, so ten languages cost nothing in bundle size. For an Indian product this is more concrete than it sounds, since Hindi or Marathi content is a genuine reach question rather than a hypothetical.

Locale Routing

A dynamic segment and a middleware default.

[locale] segment plus a middleware default
app/
  [locale]/
    layout.tsx
    page.tsx                    // /en, /hi
    problems/page.tsx           // /en/problems

export async function generateStaticParams() {
  return [{ locale: 'en' }, { locale: 'hi' }, { locale: 'mr' }]
}

// middleware.ts — send a bare URL to a locale
const LOCALES = ['en', 'hi', 'mr']
export function middleware(req: NextRequest) {
  const { pathname } = req.nextUrl
  if (LOCALES.some((l) => pathname.startsWith(`/${l}/`) || pathname === `/${l}`))
    return

  const preferred = req.cookies.get('locale')?.value
    ?? pickFromAcceptLanguage(req.headers.get('accept-language'))
    ?? 'en'
  return NextResponse.redirect(new URL(`/${preferred}${pathname}`, req.url))
}

// Tell search engines about the alternatives, or they treat the
// translations as duplicate content:
export async function generateMetadata({ params }) {
  const { locale } = await params
  return {
    alternates: {
      canonical: `/${locale}`,
      languages: { en: '/en', hi: '/hi', mr: '/mr' },
    },
  }
}
<html lang={locale}>          // and set this, for screen readers

Dictionaries on the Server

Load only the active locale — the advantage over client-side i18n.

Dynamic import per locale, server-side
// dictionaries/en.json, hi.json, mr.json
// dictionaries.ts
const dictionaries = {
  en: () => import('./dictionaries/en.json').then((m) => m.default),
  hi: () => import('./dictionaries/hi.json').then((m) => m.default),
}
export const getDictionary = async (locale: string) =>
  (dictionaries[locale] ?? dictionaries.en)()

// A server component
export default async function Page({ params }) {
  const { locale } = await params
  const t = await getDictionary(locale)
  return <h1>{t.problems.title}</h1>
}

// Only the active locale's JSON is sent. A client-side library ships
// every language to every user — the whole reason to do this on the
// server.

// For a client component, pass the strings it needs as props rather
// than the whole dictionary:
<FilterBar labels={{ all: t.filters.all, easy: t.filters.easy }} />

// next-intl and next-i18next package all of this, including plurals,
// interpolation and date formatting. Worth it beyond a few dozen
// strings.

What Actually Needs Translating

The judgement, and the formatting that must be locale-aware regardless.

Intl with an explicit locale, always
// Translate: navigation, buttons, form labels, errors, marketing copy,
//            empty states — everything that frames the product.
// Often not: deep technical content, where the audience reads English
//            anyway and a bad machine translation is worse than none.

// A pragmatic split for an Indian edtech product: translate the shell
// and the onboarding, keep the concept text in English, and let Aria
// explain in the user's language on request. Cheaper, and closer to
// how students actually study.

// Formatting must be locale-aware even when the text is not:
new Intl.NumberFormat(locale).format(125000)
//   en-IN -> 1,25,000        (lakh grouping — NOT 125,000)
//   en-US -> 125,000
new Intl.NumberFormat('en-IN', { style: 'currency', currency: 'INR' })
new Intl.DateTimeFormat(locale, { dateStyle: 'medium' }).format(d)
new Intl.RelativeTimeFormat(locale).format(-2, 'day')

// Always pass the locale explicitly. Without it, the server's default
// is used — which is how a build machine's locale ends up formatting
// an Indian user's numbers, and a hydration mismatch appears.

// Hardcoded English in a date or a plural is the most common miss:
`${count} items`               // wrong at count === 1, in any language
new Intl.PluralRules(locale)   // or a library's plural support

Key Points to Remember

  • 1The App Router has no built-in i18n routing — use a [locale] segment with middleware for the default
  • 2Loading dictionaries on the server sends only the active locale, unlike client-side i18n libraries
  • 3Declare alternates.languages or search engines treat translations as duplicate content
  • 4Intl formatting must be given an explicit locale, or the server default formats the user's numbers and dates
  • 5en-IN groups digits in lakhs, so number formatting is not just a separator swap

Interview Questions

Sign in to ask Aria
1

How do you add locale routing in the App Router?

Medium
2

Why is server-side dictionary loading better than a client i18n library?

Hard
3

What goes wrong if you call Intl.NumberFormat without a locale?

Medium

Ask Aria about Internationalisation

Your personal AI tutor — ask anything about this concept

Revision Status

Personal Notes

Sign in to save personal notes for this topic.

Discussion

Sign in to join the discussion.

Loading discussion…