PVR Tech Studio

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.

4 min read
Updated August 17, 2026

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:

URLRenders
/Redirects to the default locale
/enHome, English
/ja/apps/chatChat, Japanese
/de/settings/profileProfile, 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

FileRole
src/i18n/routing.tsLocale list and prefix strategy
src/i18n/request.tsPer-request config; loads the message bundle
src/i18n/navigation.tsLocale-aware Link, usePathname, useRouter
src/platform/nav.tsxMaps that onto the shared navigation surface
src/app/[locale]/layout.tsxValidates the segment, sets the request locale
scripts/build-messages.mjsCompiles 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.ts stores /apps/chat, not /en/apps/chat;
  • route lookups compare against unprefixed paths;
  • Link to="/apps/chat" emits /ja/apps/chat when 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.

const navigate = useNavigate()
 
navigate('/apps/contacts')
navigate('/auth/login', {replace: true})
navigate(-1) // history back

Reading the current path

const pathname = usePathname() // '/apps/chat' — already de-localised

API / Props

The navigation surface, identical in both editions so shared components compile against either:

ExportSignature
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:

  1. Add the entry to src/i18n/languages.ts.
  2. Add src/locales/<code>/ translation files.
  3. Run npm run codegen to 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.

Was this page helpful?