Internationalisation
AdvancedA 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.
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 readersDictionaries on the Server
Load only the active locale — the advantage over client-side i18n.
// 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.
// 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 supportKey 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 AriaHow do you add locale routing in the App Router?
Why is server-side dictionary loading better than a client i18n library?
What goes wrong if you call Intl.NumberFormat without a locale?
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.