Framework i18n & Component Routing
Framework i18n is the discipline of making locale a first-class concern in your router, your components, and your build output — so that the right language renders on the server, survives hydration on the client, and ships only the bundle a user actually needs. Most teams treat translation as a runtime afterthought; the cost shows up as hydration mismatches, flash-of-untranslated-content, and locale dictionaries shipped to every visitor regardless of their language. This area collects the routing, component, and bundling patterns that keep locale deterministic across React/Next.js, Vue/Nuxt, Angular, SvelteKit, and Astro.
The hardest problems here are not “how do I translate a string.” They are: which locale wins when the URL, the cookie, and the Accept-Language header disagree; how to render a translated tree on the server and rehydrate it on the client without a checksum error; and how to lazy-load a Japanese dictionary for a Japanese visitor without blocking first paint for everyone else. Those three questions — routing, hydration safety, and bundle delivery — structure everything below.
Architecture overview: where this fits
Framework i18n sits in the middle of a three-stage pipeline. Upstream, the Core i18n Architecture & Locale Negotiation area defines what a locale is and how it is chosen — the negotiation algorithm, the fallback chain, ICU message semantics, and CLDR plural rules. Those are framework-neutral truths. Downstream, the Translation Workflows & CI/CD Pipeline Sync area governs how translated strings get into and out of your repository — extraction, review, and the gates that block a merge when keys go missing.
This area is the glue. It takes the resolved locale from the negotiation layer and the validated dictionaries from the workflow layer and wires them into a specific framework’s router, render lifecycle, and bundler. A correct implementation is mostly about preserving invariants across that boundary: the locale the negotiation layer resolved must be the locale the server renders, the locale the client hydrates, and the locale the bundler keys its chunks on. When those four drift apart, you get the failures this whole area exists to prevent.
Three concrete concerns dominate the framework layer, and the rest of this page walks each one with runnable steps: locale-aware routing (concept 1), component translation and hydration safety (concept 2), and lazy locale bundle delivery (concept 3). After those, cross-cutting concerns, the non-negotiable principles, and a troubleshooting table.
Concept 1 — Locale-aware routing
Routing is where a locale becomes part of the page identity. The goal is a single, deterministic resolution that runs before any component renders, so the server and client never disagree about which language to show. Resolve once, at the edge, and propagate the result; never re-resolve inside a component.
The resolution order is the contract every framework adapter must honor: an explicit URL prefix (/de/pricing) is authoritative because it is shareable and cacheable; an explicit cookie override is next because the user chose it; the Accept-Language header is a hint, parsed with q-weights; the default locale is the last resort. This mirrors the algorithm formalized in the locale negotiation strategies reference, which you should treat as the spec the router implements.
Implementation steps
- Define the supported set and default once, in a module both the edge runtime and the bundler can import. This single source prevents the router and the chunker from disagreeing about which locales exist.
- Detect at the edge, before the framework renders. In Next.js this is
middleware.ts; in SvelteKit ahooks.server.tshandle; in Nuxt a server middleware. Edge detection keeps locale resolution off the critical render path. - Normalize the path. If the first segment is a valid locale, pass through. If not, resolve from cookie then header, then
rewrite(not redirect) so the canonical URL stays clean while internal routing carries the locale. - Persist the decision in a long-lived cookie so the next request skips header parsing and stays consistent with the URL.
- Expose the resolved locale to the render layer via a request-scoped value the framework can read during SSR — never a module-global, which leaks across concurrent requests on the server.
// middleware/locale-resolver.ts
import { NextRequest, NextResponse } from 'next/server';
const SUPPORTED_LOCALES = ['en', 'es', 'de', 'ja'] as const;
const DEFAULT_LOCALE = 'en';
export function localeMiddleware(req: NextRequest) {
const url = req.nextUrl.clone();
const pathLocale = url.pathname.split('/')[1];
// URL prefix is authoritative — pass straight through.
if (SUPPORTED_LOCALES.includes(pathLocale as typeof SUPPORTED_LOCALES[number])) {
return NextResponse.next();
}
// No prefix: cookie override beats header, header beats default.
const cookieLocale = req.cookies.get('NEXT_LOCALE')?.value;
const acceptLang = req.headers.get('accept-language') || '';
const headerLocale = acceptLang.split(',')[0]?.split('-')[0];
const resolved =
(SUPPORTED_LOCALES as readonly string[]).includes(cookieLocale ?? '')
? cookieLocale!
: (SUPPORTED_LOCALES as readonly string[]).includes(headerLocale ?? '')
? headerLocale!
: DEFAULT_LOCALE;
// rewrite, not redirect: canonical URL unchanged, routing carries the locale.
const res = NextResponse.rewrite(new URL(`/${resolved}${url.pathname}`, req.url));
res.cookies.set('NEXT_LOCALE', resolved, { path: '/', maxAge: 31536000 });
return res;
}
The framework-specific particulars — where middleware lives, how the rewrite interacts with the App Router, and how to avoid double-prefixing — are covered in the Next.js i18n routing setup and, for compiler-driven apps, SvelteKit internationalization basics.
Concept 2 — Component translation and hydration safety
Once a locale is resolved, components consume it. The framework-agnostic rule is: components read translations from a context or store, never from a module-level singleton, and the messages the server used to render must be serialized into the page and read back verbatim during hydration. Hydration safety is the single most common place framework i18n breaks, because the failure is silent until React, Vue, or Svelte throws a checksum warning and discards the server HTML.
A hydration mismatch happens whenever the client computes a different tree than the server emitted. With i18n, the usual trigger is the client resolving locale from navigator.language (or an empty cookie) while the server resolved it from the URL or header. The fix is structural: the locale and the active message dictionary are part of the serialized SSR state, and the client provider initializes from that state — not from anything it re-derives in the browser.
Implementation steps
- Build a typed translation context so missing keys fail at compile time, not as a blank string at runtime.
- Initialize the provider from serialized server state, not from a fresh client-side detection.
- Defer any locale switch until after hydration completes — switching during the first paint is what corrupts the checksum.
- Serialize the exact messages the server rendered into a
<script>payload the client reads synchronously before mounting.
// lib/i18n/context.ts
import { createContext, useContext } from 'react';
interface I18nContextType {
locale: string;
// typed keys catch missing strings at compile time
t: (key: string, params?: Record<string, string | number>) => string;
}
export const I18nContext = createContext<I18nContextType | null>(null);
export const useTranslation = () => {
const ctx = useContext(I18nContext);
if (!ctx) throw new Error('useTranslation must be used within I18nProvider');
return ctx;
};
// On the client, hydrate from the SAME state the server serialized.
// window.__I18N__ is written into the HTML during SSR, so the client
// provider never re-detects locale and the rendered tree matches exactly.
declare global {
interface Window { __I18N__?: { locale: string; messages: Record<string, string> }; }
}
For the runtime formatting itself — plurals, gender, dates, and numbers inside a component — defer to an ICU formatter so component logic stays free of locale branches. This is the same ICU message format layer the core area specifies, just invoked from a hook:
// lib/i18n/formatter.ts
import { IntlMessageFormat } from 'intl-messageformat';
export const formatMessage = (
message: string,
values: Record<string, string | number>,
locale: string
): string => new IntlMessageFormat(message, locale).format(values) as string;
// formatMessage('You have {count, plural, =0 {no items} one {# item} other {# items}}',
// { count: 5 }, 'en') → "You have 5 items"
Framework treatments diverge here: the React i18next component patterns page covers suspense-aware loading and Trans interpolation, while the Vue i18n Composition API guide shows the reactive equivalent that keeps locale state out of the template. Enterprise Angular apps with strict dependency injection follow the Angular localization module setup.
Concept 3 — Lazy locale bundles and store synchronization
A Japanese visitor should never download the German dictionary. Yet the naive setup imports every locale’s messages into the main chunk, inflating Time to First Byte and Largest Contentful Paint for everyone. The framework layer’s third job is to split dictionaries by locale (and ideally by route) and load only what the active locale needs, fetching others on demand at navigation time.
There are two halves: a bundler split that emits one chunk per locale, and a runtime loader that imports the next locale’s chunk when the user switches, then swaps it into the store so every subscribed component re-renders consistently.
Implementation steps
- Key chunks by locale in the bundler using
manualChunks, so each language’s JSON becomes its own cacheable file. - Load the active locale eagerly, others lazily, via dynamic
import()keyed on the resolved locale. - Synchronize the store on switch, updating Redux/Pinia/NgRx and the
<html lang>/dirattributes in one place so navigation never leaves locale state half-updated. - Cache fetched dictionaries so a return visit to a previously loaded locale is instant.
// vite.config.ts — one chunk per locale
import { defineConfig } from 'vite';
export default defineConfig({
build: {
rollupOptions: {
output: {
manualChunks(id) {
if (id.includes('/locales/')) {
const m = id.match(/\/locales\/([a-z]{2,5})\//);
return m ? `locale-${m[1]}` : undefined; // locale-ja, locale-de, ...
}
},
},
},
},
});
// store/locale.middleware.ts (Redux Toolkit) — lazy-load + sync on switch
import type { Middleware } from '@reduxjs/toolkit';
export const localeSyncMiddleware: Middleware = (store) => (next) => async (action) => {
const result = next(action);
if (typeof action === 'object' && action && 'type' in action
&& action.type === 'locale/setLocale') {
const { locale } = (action as { payload: { locale: string } }).payload;
// pull the next locale's chunk only when the user actually switches
const messages = await import(`./locales/${locale}.json`);
store.dispatch({ type: 'locale/messagesLoaded', payload: messages.default });
// single place that touches the document — keeps lang/dir in lockstep
document.documentElement.setAttribute('lang', locale);
document.documentElement.setAttribute('dir', ['ar', 'he', 'fa'].includes(locale) ? 'rtl' : 'ltr');
window.localStorage.setItem('app_locale', locale);
}
return result;
};
The dir toggle above is the entry point into right-to-left layout work; setting the document direction is necessary but not sufficient, which is why mirrored icons and logical CSS properties get their own treatment in the core area’s RTL & bidirectional layout engineering page.
Cross-cutting concerns
Edge-cache headers. Locale-split assets are immutable once fingerprinted, but the HTML is locale-varying. Cache JSON dictionaries aggressively and let the HTML revalidate, and always emit a Vary signal when locale derives from headers so a CDN never serves a German page to a Spanish request.
# nginx.conf
location ~* ^/locales/[a-z]{2,5}/ {
add_header Cache-Control "public, max-age=31536000, stale-while-revalidate=86400";
add_header X-Content-Type-Options "nosniff";
gzip on; gzip_types application/json;
}
location ~* \.(js|css|woff2)$ {
add_header Cache-Control "public, max-age=31536000, immutable";
}
Accessibility. The lang attribute on <html> is not cosmetic — screen readers switch pronunciation rules based on it, and it must match the rendered language on every navigation, including soft client-side route changes. Pair it with dir so assistive tech announces reading order correctly. Translated aria-label and alt text must flow through the same dictionary as visible copy, never be hard-coded.
Compliance and SEO-adjacent correctness. Locale-prefixed URLs should be stable and shareable so a link to /ja/pricing always renders Japanese regardless of the recipient’s cookie. Emit hreflang alternates for each supported locale of a page, and ensure the negotiation layer’s region-to-language fallback never returns a 404 when a user requests a region you do not specifically target (de-AT should serve de, not break).
Translation freshness. None of the above matters if the dictionaries are stale. The framework layer assumes a CI gate already failed the build on untranslated keys; that gate, and the extraction that feeds it, live in the Translation Workflows & CI/CD Pipeline Sync area.
Five non-negotiable engineering principles
- Resolve locale exactly once, at the edge. Every component reads the resolved value from request-scoped state. No component re-derives locale from
navigator.language; that is the root of most hydration mismatches. - Serialize the SSR i18n state and hydrate from it verbatim. The client provider initializes from the serialized locale and messages — never from fresh client-side detection.
- Defer locale switches until after hydration. Switching during first paint corrupts the framework’s checksum and discards the server-rendered tree.
- Ship only the active locale’s dictionary. Split chunks by locale, load others lazily on navigation, and cache what you fetch.
- Treat
langanddiras a single atomic update. Set both together, in one place, on every locale change — including soft client-side navigations — so accessibility and RTL never drift from the rendered language.
Troubleshooting & gotchas
| Symptom | Root cause + fix |
|---|---|
| React/Vue “hydration mismatch” warning after a locale switch | Client resolved a different locale than the server. Serialize the SSR locale+messages and initialize the provider from that state; defer switches until after mount. See the Next.js hydration mismatch fix. |
| Flash of untranslated keys before messages load | Active-locale dictionary is lazy instead of eager. Load the resolved locale’s bundle synchronously during SSR; lazy-load only the other locales. |
/de-AT/... returns 404 |
No region-to-language fallback. Map region requests down to the base language before routing — see region-to-language fallback without 404. |
| Wrong language served from CDN to some users | HTML cached without varying on the locale signal. Vary on the locale cookie/header and keep locale in the URL so cache keys differ per language. |
| Every visitor downloads all locales | manualChunks not keyed on /locales/. Add the locale chunk rule so each language is its own cacheable file. |
| Plural form wrong in Polish/Arabic despite correct count | Component string-concatenates instead of using an ICU plural. Route counts through the ICU formatter; see pluralization in Arabic and Slavic languages. |
| Module-global locale leaks across requests in SSR | Locale stored in a module singleton on the server. Use request-scoped storage; never mutate a shared module variable per request. |
FAQ
Why does a locale switch trigger a hydration mismatch?
Because the server rendered the tree for one locale and the client computed a different one. This happens when the client resolves locale from navigator.language, an empty cookie, or localStorage instead of the locale the server already resolved. Serialize the server’s locale and the exact messages it used into the page, initialize the client provider from that serialized state, and defer any actual locale change until after hydration completes. The server and client then produce identical trees and the checksum passes.
Should the locale live in the URL, a cookie, or both?
Both, with a clear precedence. The URL prefix is authoritative because it is shareable and cacheable — a link to /ja/pricing must render Japanese for anyone. The cookie persists the user’s explicit choice for requests that arrive without a prefix, and the Accept-Language header is the fallback hint. Resolve in that order (URL > cookie > header > default), as the locale negotiation strategies reference describes.
How do I avoid shipping every locale’s dictionary to every user?
Split dictionaries into one bundle per locale at build time (with manualChunks in Vite/Rollup or the framework’s equivalent), load only the active locale eagerly during SSR, and dynamically import() other locales when the user navigates to them. Cache fetched dictionaries so a return visit to a previously loaded language is instant. This keeps the initial payload proportional to one language, not all of them.
Does this replace the core i18n negotiation and ICU work?
No. The Core i18n Architecture & Locale Negotiation area defines the locale model, negotiation algorithm, fallback chains, and ICU message semantics independent of any framework. This area consumes those decisions and wires them into a specific framework’s router, render lifecycle, and bundler. Keep the framework-neutral truths in the core layer and the framework wiring here.
How do RTL languages fit into framework routing?
Setting dir="rtl" on <html> when the locale is Arabic, Hebrew, or Persian is the routing-layer entry point, and it must update atomically with lang on every navigation. But direction alone does not flip mirrored icons or convert physical CSS margins to logical properties — that work lives in RTL & bidirectional layout engineering.
Related
- Next.js i18n Routing Setup — edge middleware, App Router locale prefixing, and rewrite-vs-redirect handling.
- React i18next Component Patterns — suspense-aware loading and
Transinterpolation inside components. - Vue i18n Composition API Guide — reactive locale state kept out of templates.
- SvelteKit Internationalization Basics — compile-time string replacement and route prefixing.
- Locale Negotiation Strategies — the resolution algorithm this area’s routers implement.
Part of the i18n & l10n Pipelines documentation.