Localized Routing
Every page has an explicit locale in its URL — /en/apps/chat, /ja/apps/chat — which is what makes per-locale indexing possible.
Overview
Routing is handled by next-intl, configured from the shared language registry so there is one list of locales rather than two. Adding a language never means editing the routing config.
localePrefix is 'always', so:
| URL | Renders |
|---|---|
/ | Redirects to the default locale |
/en | Home, English |
/ja/apps/chat | Chat, Japanese |
/de/settings/profile | Profile, German |
Every locale gets a distinct, crawlable URL, and the sitemap emits alternates.languages so search
engines understand the translations are the same page rather than duplicate content.
Architecture & files
| File | Role |
|---|---|
src/i18n/routing.ts | Locale list and prefix strategy |
src/i18n/request.ts | Per-request config; loads the message bundle |
src/i18n/navigation.ts | Locale-aware Link, usePathname, useRouter |
src/platform/nav.tsx | Maps that onto the shared navigation surface |
src/app/[locale]/layout.tsx | Validates the segment, sets the request locale |
scripts/build-messages.mjs | Compiles the message bundles |
export const routing = defineRouting({
locales: LANGUAGE_CODES,
defaultLocale: DEFAULT_LANGUAGE,
localePrefix: 'always',
})The shared UI stays locale-unaware
This is the part worth understanding, because it explains why adding a page needs no locale handling. The navigation adapter wraps next-intl's locale-aware primitives, so:
menu.tsstores/apps/chat, not/en/apps/chat;- route lookups compare against unprefixed paths;
Link to="/apps/chat"emits/ja/apps/chatwhen the active locale is Japanese.
The adapter also reimplements NavLink, because next-intl ships Link but not NavLink. Active state
matches by prefix (exact with end), and the prefix match guards the segment boundary so
/apps/chat does not light up for /apps/chatroom.
Usage
Linking
import {Link, NavLink} from '@/platform/nav'
<Link to="/apps/calendar">Calendar</Link>
<NavLink to="/apps/chat" className={({isActive}) => (isActive ? 'text-primary' : 'text-muted-foreground')}>
Chat
</NavLink>No locale anywhere. That is the point.
Navigating programmatically
const navigate = useNavigate()
navigate('/apps/contacts')
navigate('/auth/login', {replace: true})
navigate(-1) // history backReading the current path
const pathname = usePathname() // '/apps/chat' — already de-localisedAPI / Props
The navigation surface, identical in both editions so shared components compile against either:
| Export | Signature |
|---|---|
Link | {to, ...} |
NavLink | {to, end?, className?, children?} — both accept a render function |
useNavigate() | (to: string | number, opts?: {replace?: boolean}) => void |
usePathname() | () => string, de-localised |
useSearchParams() | Query-string access |
usePathname returns a plain string rather than a location object — the App Router has no location object
to hand back, and the pathname is all the shared components ever needed.
Configuration & customization
Adding a language
The locale list is derived from the shared registry, so:
- Add the entry to
src/i18n/languages.ts. - Add
src/locales/<code>/translation files. - Run
npm run codegento compile the message bundles.
Routing, the switcher, the sitemap and generateStaticParams all pick it up. There is no routing file to
edit.
Changing the prefix strategy
localePrefix can be 'as-needed' to drop the prefix for the default locale. Be deliberate: it makes the
default locale's URLs shorter but means one locale has no explicit URL, which complicates canonical tags.
Notes & gotchas
Messages are compiled, not read at runtime. npm run codegen turns the shared flat translation files
into per-locale bundles, wired to run before dev and build. request.ts imports them with a template
literal, so the bundler folds every match into the build output — which is why you will not find a
messages directory in a standalone build. Nothing is missing; the JSON is inside the compiled chunks.
Bundles are backfilled from English. next-intl has no cross-locale fallback of its own, so a partially-translated locale would surface a raw key. The build fills every gap from English instead, which is why a locale at 91% translated still renders completely.
A new namespace must be registered in two places — the shared namespace list and the English manifest. The manifest is what the message build validates against, so a namespace missing from it fails that build rather than silently producing a bundle with a hole.
An unknown locale 404s at the layout, not in the middleware. The layout checks the segment against the
locale list and calls notFound(). The [locale] segment would otherwise match literally anything.
Adding a page is ordinary App Router work. Create src/app/[locale]/(app)/<path>/page.tsx — copy an
existing one as a starting point, since they share a shape — then add the menu leaf in src/data/menu.ts
and its nav: label to the locale files. There is no central route table to update: the file system is
the registry. npm run codegen compiles the translation bundles and does not generate routes, so you do
not need to run it after adding a page unless you also added translation keys.
Locale resolution runs before the auth guard, in the same request-time handler — see Authentication & Route Guard.
CJK fonts are not preloaded. Japanese and Chinese faces are large, so they download only for the
locales that use them, selected by the lang attribute the layout already sets from the URL segment.
Related
Internationalization
namespaces, the translation tool, and the shared locale files
Authentication & Route Guard
why the guard de-localises first
Architecture & Routing
the route registry and presentation flags
Server Rendering & First Paint
the layout that validates the segment
Sidebar & Navigation
the menu data that stays unprefixed
Was this page helpful?
