text dependencies.
// lib/i18n/router.ts
import { defineRouting } from 'next-intl/routing';
import { LOCALE_REGISTRY } from '@/config/locales';
export const routing = defineRouting({
locales: LOCALE_REGISTRY.enabled,
defaultLocale: LOCALE_REGISTRY.default,
localePrefix: 'always',
});
// lib/i18n/navigation.ts
import { createNavigation } from 'next-intl/navigation';
import { routing } from './router';
export const { Link: LocalizedLink, redirect: i18nRedirect, usePathname, useRouter } =
createNavigation(routing);
Step 3: Relocate Route Tree
Move all page components under a dynamic [locale] segment. The root layout must live inside this segment to satisfy Next.js 16's parameter resolution requirements.
app/
βββ [locale]/
β βββ layout.tsx
β βββ page.tsx
β βββ products/
β βββ collections/
β βββ account/
βββ api/
βββ sitemap.xml/
βββ robots.ts
βββ globals.css
Update generic type parameters in layout and page components to reflect the new path structure:
// app/[locale]/layout.tsx
import type { LayoutProps } from '@/types/next';
export default function LocaleRootLayout({ children, params }: LayoutProps<'/[locale]'>) {
// Layout logic
return <>{children}</>;
}
Step 4: Resolve Locale via Root Params
Replace header-based locale detection with next/root-params. This keeps the resolution cacheable and compatible with static sample generation.
// lib/params.ts
import { notFound } from 'next/navigation';
import { locale as rootLocale } from 'next/root-params';
import { LOCALE_REGISTRY, type SupportedLocale } from '@/config/locales';
export async function resolveCurrentLocale(): Promise<SupportedLocale> {
const detected = await rootLocale();
const isValid = LOCALE_REGISTRY.enabled.includes(detected as SupportedLocale);
if (!detected || !isValid) {
notFound();
}
return detected as SupportedLocale;
}
Step 5: Cache-Safe Message Resolution
Load translation catalogs without destructuring request headers. Reading headers inside a cached tree forces dynamic rendering and breaks instant sample validation.
// lib/i18n/loader.ts
import { hasLocale } from 'next-intl';
import { getRequestConfig } from 'next-intl/server';
import { resolveCurrentLocale } from '@/lib/params';
import { routing } from './router';
import type enCatalog from './messages/en.json';
const catalogLoaders: Record<string, () => Promise<{ default: typeof enCatalog }>> = {
'en-US': () => import('./messages/en.json'),
'en-GB': () => import('./messages/en.json'),
'de-DE': () => import('./messages/de.json'),
'fr-FR': () => import('./messages/fr.json'),
};
export default getRequestConfig(async () => {
const requested = await resolveCurrentLocale();
const activeLocale = hasLocale(routing.locales, requested) ? requested : routing.defaultLocale;
const loader = catalogLoaders[activeLocale] ?? catalogLoaders[routing.defaultLocale];
const messages = (await loader()).default;
return { locale: activeLocale, messages };
});
Step 6: Middleware Routing
Use proxy.ts (Next.js 16 convention) to handle unprefixed path normalization. The middleware rewrites incoming requests to include the locale segment, ensuring clean URLs in the browser while maintaining strict routing rules.
// proxy.ts
import createMiddleware from 'next-intl/middleware';
import { type NextRequest, NextResponse } from 'next/server';
import { routing } from '@/lib/i18n/router';
const i18nHandler = createMiddleware(routing);
export default function middleware(request: NextRequest): NextResponse {
const response = i18nHandler(request);
if (!response.ok) return response;
const rewriteTarget = response.headers.get('x-middleware-rewrite');
if (!rewriteTarget) return response;
const url = new URL(rewriteTarget, request.url);
const segments = url.pathname.split('/').filter(Boolean);
const normalizedPath = `/${segments.join('/')}`;
return NextResponse.rewrite(new URL(normalizedPath, request.url), {
headers: response.headers,
});
}
export const config = {
matcher: ['/((?!api|_next|_vercel|.*\\..*).*)'],
};
Step 7: SEO & Canonical Generation
Generate locale-aware canonical URLs and hreflang alternates at build time. This ensures search engines index each language variant correctly without duplicate content penalties.
// lib/seo.ts
import { LOCALE_REGISTRY } from '@/config/locales';
import { resolveCurrentLocale } from '@/lib/params';
import type { Metadata } from 'next';
function prefixPath(locale: string, path: string): string {
const clean = path === '/' ? '' : path;
return `/${locale}${clean}`;
}
export async function generateLocaleMetadata(pathname: string, searchParams?: URLSearchParams): Promise<Metadata['alternates']> {
const current = await resolveCurrentLocale();
const canonical = `${process.env.NEXT_PUBLIC_BASE_URL}${prefixPath(current, pathname)}${searchParams ? `?${searchParams.toString()}` : ''}`;
const languages: Record<string, string> = {};
for (const loc of LOCALE_REGISTRY.enabled) {
languages[loc] = `${process.env.NEXT_PUBLIC_BASE_URL}${prefixPath(loc, pathname)}`;
}
return { canonical, languages };
}
Architecture Rationale
- Root-param resolution eliminates request-header dependencies, enabling full static prerendering.
- Middleware rewrites handle unprefixed URLs at the edge, reducing client-side hydration overhead.
- Explicit
instant samples satisfy Next.js 16's cache validation, preventing build-time blocking errors.
- Separation of navigation utilities prevents accidental request-context leakage into cached component trees.
Pitfall Guide
1. Coexisting Root Layouts
Explanation: Placing app/layout.tsx alongside app/[locale]/layout.tsx breaks root-param resolution. Next.js cannot determine which layout should own the dynamic segment, causing rootLocale() to return undefined.
Fix: Delete the top-level app/layout.tsx after moving it into the [locale] segment. Only the locale-scoped layout should exist.
2. Misusing setRequestLocale
Explanation: Calling setRequestLocale(locale) inside layouts or pages writes to a request-scoped store, forcing dynamic rendering. This defeats cacheComponents: true and triggers prerender failures.
Fix: Rely on getRequestConfig with root-param resolution. The locale is already part of the cache key, making explicit store mutations unnecessary.
3. Global <Link> Replacement
Explanation: Swapping next/link for next-intl's <LocalizedLink> across the entire app causes blocking route errors during static generation. The localized link reads request context on render, which is incompatible with cached server components.
Fix: Keep next/link for internal navigation. Use the localized variant only in client components that explicitly handle locale switching. Let middleware handle unprefixed path normalization.
4. Omitting Locale in instant Samples
Explanation: Routes exporting instant (products, collections, search) require locale in their unstable_samples params. Missing this declaration causes build failures with explicit root-param errors.
Fix: Add locale to every sample object:
export const instant = {
unstable_samples: [
{ params: { locale: 'en-US', handle: '__placeholder__' }, searchParams: {} },
],
};
Explanation: If any layout-level server component reads headers(), every instant sample must declare the accessed headers. Forgetting this causes explicit build errors about undefined header access.
Fix: Include a headers array in samples:
headers: [['x-vercel-ip-postal-code', null]],
Use null to indicate the header may be absent during prerendering.
6. Type Narrowing Failures with redirect()
Explanation: next-intl's redirect returns void, preventing TypeScript from recognizing it as a control-flow terminator. This causes type errors when accessing variables after the redirect call.
Fix: Use next/navigation's redirect (returns never) and manually prefix the locale:
import { redirect } from 'next/navigation';
import { resolveCurrentLocale } from '@/lib/params';
if (!session) redirect(`/${await resolveCurrentLocale()}/account/login`);
Explanation: Destructuring { locale } from getRequestConfig callbacks reads the x-next-intl-locale header, forcing dynamic rendering and requiring explicit header declarations in all samples.
Fix: Resolve the locale via resolveCurrentLocale() instead. This keeps the message loader cacheable and sample-independent.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Multi-language storefront without region-specific pricing | Root-param routing + next-intl | Decouples translation from commerce logic, enables full static prerendering | Low (middleware + build-time generation) |
| Region-aware pricing, inventory, and payments | Shopify Markets integration | Handles currency conversion, tax rules, and payment gateways natively | Medium (API calls + checkout complexity) |
| High-traffic product catalog | Static prerendering with instant samples | Eliminates server compute per request, reduces TTFB by 60-80% | Low (build time increase, runtime savings) |
| Dynamic locale switching in client components | usePathname + manual segment swap | Avoids request-context leakage, maintains cache boundaries | Negligible (client-side only) |
Configuration Template
Copy this structure into your project root. Adjust paths and environment variables to match your deployment target.
// config/locales.ts
export type SupportedLocale = 'en-US' | 'en-GB' | 'de-DE' | 'fr-FR';
export const LOCALE_REGISTRY = {
enabled: ['en-US', 'en-GB', 'de-DE', 'fr-FR'] as const,
default: 'en-US' as SupportedLocale,
currencyMap: { 'en-US': 'USD', 'en-GB': 'GBP', 'de-DE': 'EUR', 'fr-FR': 'EUR' } as Record<SupportedLocale, string>,
};
// lib/params.ts
import { notFound } from 'next/navigation';
import { locale as rootLocale } from 'next/root-params';
import { LOCALE_REGISTRY, type SupportedLocale } from '@/config/locales';
export async function resolveCurrentLocale(): Promise<SupportedLocale> {
const detected = await rootLocale();
if (!detected || !LOCALE_REGISTRY.enabled.includes(detected as SupportedLocale)) notFound();
return detected as SupportedLocale;
}
// proxy.ts
import createMiddleware from 'next-intl/middleware';
import { type NextRequest, NextResponse } from 'next/server';
import { routing } from '@/lib/i18n/router';
const i18nHandler = createMiddleware(routing);
export default function middleware(request: NextRequest): NextResponse {
const response = i18nHandler(request);
if (!response.ok) return response;
const rewriteTarget = response.headers.get('x-middleware-rewrite');
if (!rewriteTarget) return response;
const url = new URL(rewriteTarget, request.url);
const segments = url.pathname.split('/').filter(Boolean);
return NextResponse.rewrite(new URL(`/${segments.join('/')}`, request.url), { headers: response.headers });
}
export const config = { matcher: ['/((?!api|_next|_vercel|.*\\..*).*)'] };
Quick Start Guide
- Initialize registry: Create
config/locales.ts with your supported languages and default variant.
- Restructure routes: Move
app/layout.tsx and all page components into app/[locale]/. Delete the original root layout.
- Wire routing: Add
lib/i18n/router.ts and lib/i18n/navigation.ts using next-intl's defineRouting and createNavigation.
- Deploy middleware: Place
proxy.ts in the project root with the provided matcher configuration.
- Validate samples: Update all
instant exports to include locale in params and declare any accessed headers. Run next build to verify static generation succeeds.