Table of Contents
Table of Contents
- Why RSC Changes i18n Fundamentals
- Middleware: Locale Detection Before the Request Hits Your App
- App Router Structure: Locale as a Layout Segment
- Server Components: Fetching Translations at the Edge
- Type-Safe Translation Keys
- Client Component Hydration: Translation Islands
- Static Routes: generateStaticParams
- Comparison with next-intl and next-i18next
- What We'd Do Differently in 2026
- Getting Started
The App Router changed how we think about data fetching in Next.js. It also changed how we should think about i18n. Most of the guides out there still treat translation as a client-side concern—a bundle of JSON files loaded at runtime. That made sense in the Pages Router era. In 2026, with React Server Components as the default, we can do better.
This post walks through the patterns we've settled on for RSC-native i18n: how to detect locale in middleware, how to fetch translations in server components without client overhead, how to hydrate client islands cleanly, and how CDN-first delivery changes the equation entirely.
Why RSC Changes i18n Fundamentals
In the Pages Router, getServerSideProps or getStaticProps fetched translations server-side, then serialized them into the page bundle. The client re-hydrated everything, including translation dictionaries that never changed per-user.
Server Components eliminate that pattern. There's no hydration step for RSC output—the HTML is final. This means we can fetch translations in a server component, render translated strings directly into HTML, and ship zero translation-related JavaScript to the client. No dictionary bundle, no runtime lookup, no hydration mismatch.
The catch: client components that need translations (interactive forms, dynamic UIs) still require some client-side access. The pattern is clear once you split the two concerns explicitly.
Middleware: Locale Detection Before the Request Hits Your App
Locale routing happens in middleware because it needs to run at the edge, before any component renders.
// middleware.ts
import { NextRequest, NextResponse } from 'next/server'
const SUPPORTED_LOCALES = ['en', 'de', 'fr', 'es'] as const
type Locale = typeof SUPPORTED_LOCALES[number]
const DEFAULT_LOCALE: Locale = 'en'
function detectLocale(request: NextRequest): Locale {
// 1. Check URL path prefix
const pathname = request.nextUrl.pathname
const pathLocale = pathname.split('/')[1] as Locale
if (SUPPORTED_LOCALES.includes(pathLocale)) {
return pathLocale
}
// 2. Check Accept-Language header
const acceptLanguage = request.headers.get('accept-language')
if (acceptLanguage) {
const preferred = acceptLanguage
.split(',')
.map(lang => lang.split(';')[0].trim().slice(0, 2) as Locale)
.find(lang => SUPPORTED_LOCALES.includes(lang))
if (preferred) return preferred
}
return DEFAULT_LOCALE
}
export function middleware(request: NextRequest) {
const locale = detectLocale(request)
const pathname = request.nextUrl.pathname
// Redirect root to locale-prefixed path
if (!SUPPORTED_LOCALES.some(l => pathname.startsWith(`/${l}`))) {
return NextResponse.redirect(
new URL(`/${locale}${pathname}`, request.url)
)
}
// Inject locale into headers for server components
const response = NextResponse.next()
response.headers.set('x-locale', locale)
return response
}
export const config = {
matcher: ['/((?!api|_next|_static|favicon.ico).*)'],
}
The x-locale header is the bridge between middleware and server components. We set it on every request so any server component can read it without prop drilling.
App Router Structure: Locale as a Layout Segment
The conventional App Router approach wraps locale-specific routes under a [locale] segment:
app/
[locale]/
layout.tsx ← sets locale context, fetches base translations
page.tsx ← home page
blog/
[slug]/
page.tsx
dashboard/
page.tsx
api/
...
The [locale]/layout.tsx is where we bootstrap translations for the entire subtree:
// app/[locale]/layout.tsx
import { headers } from 'next/headers'
import { notFound } from 'next/navigation'
import { TranslationProvider } from '@/components/translation-provider'
import { fetchTranslations } from '@/lib/translations'
const SUPPORTED_LOCALES = ['en', 'de', 'fr', 'es']
interface LayoutProps {
children: React.ReactNode
params: { locale: string }
}
export default async function LocaleLayout({ children, params }: LayoutProps) {
const { locale } = await params
if (!SUPPORTED_LOCALES.includes(locale)) {
notFound()
}
// Fetch base translations for layout-level strings
const baseTranslations = await fetchTranslations(locale, 'common')
return (
<html lang={locale}>
<body>
<TranslationProvider locale={locale} messages={baseTranslations}>
{children}
</TranslationProvider>
</body>
</html>
)
}
Server Components: Fetching Translations at the Edge
Here's the key shift: instead of bundling locale files with your app, we fetch translations from a CDN at request time. The function looks like any other async data fetch:
// lib/translations.ts
import { cache } from 'react'
type Messages = Record<string, string>
// React cache() deduplicates fetches within a single render pass
export const fetchTranslations = cache(async (
locale: string,
namespace: string
): Promise<Messages> => {
const url = `https://cdn.better-i18n.com/v1/${locale}/${namespace}.json`
const res = await fetch(url, {
next: {
revalidate: 3600, // CDN-delivered, revalidate hourly
tags: [`translations-${locale}-${namespace}`],
},
})
if (!res.ok) {
// Fall back to English rather than breaking the page
if (locale !== 'en') {
return fetchTranslations('en', namespace)
}
throw new Error(`Failed to fetch translations: ${res.status}`)
}
return res.json()
})
React's cache() wrapper means multiple server components on the same page requesting the same namespace will only trigger one network request per render pass. Combined with Next.js's fetch caching, the CDN response is typically served from the edge cache—sub-millisecond latency for translation lookups.
A server component using this pattern:
// app/[locale]/blog/[slug]/page.tsx
import { fetchTranslations } from '@/lib/translations'
import { getPost } from '@/lib/posts'
interface PageProps {
params: { locale: string; slug: string }
}
export default async function BlogPost({ params }: PageProps) {
const { locale, slug } = await params
// Both fetches happen in parallel
const [t, post] = await Promise.all([
fetchTranslations(locale, 'blog'),
getPost(slug),
])
if (!post) notFound()
return (
<article>
<h1>{post.title}</h1>
<p className="meta">
{t['blog.readTime'].replace('{n}', String(post.readTimeMinutes))}
</p>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
<footer>
<a href={`/${locale}/blog`}>{t['blog.backToList']}</a>
</footer>
</article>
)
}
No client JavaScript. No hydration. The translation key lookup happens on the server and the result is serialized HTML.
Type-Safe Translation Keys
String-based key lookups (t['blog.readTime']) break silently when keys change. We generate a type from the base locale's message file:
// types/translations.ts (generated, do not edit manually)
// Generated from: en/blog.json
export type BlogNamespaceKey =
| 'blog.readTime'
| 'blog.backToList'
| 'blog.publishedOn'
| 'blog.updatedOn'
| 'blog.authorBy'
| 'blog.sharePost'
| 'blog.relatedPosts'
// Typed accessor
export function t(messages: Record<string, string>, key: BlogNamespaceKey): string {
const value = messages[key]
if (value === undefined) {
// In development, surface the missing key
if (process.env.NODE_ENV === 'development') {
console.warn(`Missing translation key: ${key}`)
}
return key
}
return value
}
The generation step runs as part of the CI pipeline whenever translation files change. TypeScript then catches missing or renamed keys at compile time, not at runtime in production.
This is one area where a platform like Better i18n provides real leverage—type generation is automated, and the type output updates in the editor as soon as you push new keys through the dashboard.
Client Component Hydration: Translation Islands
Not everything can be a server component. Forms, modals, and interactive UIs need client-side access to translations for dynamic strings—error messages, validation feedback, async state labels.
The pattern is a context provider that serializes the relevant message subset:
// components/translation-provider.tsx
'use client'
import { createContext, useContext } from 'react'
type Messages = Record<string, string>
const TranslationContext = createContext<{
messages: Messages
locale: string
} | null>(null)
export function TranslationProvider({
children,
messages,
locale,
}: {
children: React.ReactNode
messages: Messages
locale: string
}) {
return (
<TranslationContext.Provider value={{ messages, locale }}>
{children}
</TranslationContext.Provider>
)
}
export function useTranslations(namespace?: string) {
const ctx = useContext(TranslationContext)
if (!ctx) throw new Error('useTranslations must be used inside TranslationProvider')
return {
t: (key: string) => {
const fullKey = namespace ? `${namespace}.${key}` : key
return ctx.messages[fullKey] ?? fullKey
},
locale: ctx.locale,
}
}
The layout server component fetches messages, then passes them to the provider. Client components call useTranslations() without triggering any additional fetches:
// components/contact-form.tsx
'use client'
import { useTranslations } from '@/components/translation-provider'
import { useState } from 'react'
export function ContactForm() {
const { t } = useTranslations('forms')
const [error, setError] = useState<string | null>(null)
async function handleSubmit(formData: FormData) {
const result = await submitContact(formData)
if (!result.ok) {
setError(t('contact.submitError'))
}
}
return (
<form action={handleSubmit}>
<label>{t('contact.nameLabel')}</label>
<input name="name" required />
<label>{t('contact.emailLabel')}</label>
<input name="email" type="email" required />
<button type="submit">{t('contact.submitButton')}</button>
{error && <p role="alert">{error}</p>}
</form>
)
}
The key constraint: only pass the namespace(s) the client component actually needs. Serializing the entire translation dictionary into the client bundle defeats the purpose.
Static Routes: generateStaticParams
For statically generated pages (marketing pages, blog posts, docs), we use generateStaticParams to pre-render every locale at build time:
// app/[locale]/page.tsx
export async function generateStaticParams() {
const locales = ['en', 'de', 'fr', 'es']
return locales.map(locale => ({ locale }))
}
export async function generateMetadata({ params }: { params: { locale: string } }) {
const { locale } = await params
const t = await fetchTranslations(locale, 'home')
return {
title: t['home.meta.title'],
description: t['home.meta.description'],
alternates: {
languages: {
'en': '/en',
'de': '/de',
'fr': '/fr',
'es': '/es',
},
},
}
}
With CDN-delivered translations, the build fetches message files from the CDN at build time and bakes them into the static HTML. At runtime, the CDN serves the static file—translation lookup time is effectively zero.
Comparison with next-intl and next-i18next
next-intl is the most mature App Router-compatible library. It handles a lot of the middleware and context boilerplate described above. The trade-off is that it still assumes locale files live in your repository (/messages/en.json, /messages/de.json). Every new translation requires a code deployment.
next-i18next was built for the Pages Router. In App Router projects, it's a compatibility shim at best. We'd avoid it for new projects.
CDN-first approach (what we've described above) decouples translation content from code deployments. Update a French translation, and it's live on the CDN in seconds—no redeploy, no PR, no waiting for CI. The translation team operates independently of the engineering team.
The operational overhead argument for file-based approaches ("it's simpler") breaks down quickly when you have 20 locales and a dedicated localization team. Merging translation PRs is noise, not value.
What We'd Do Differently in 2026
- Streaming translations:
<Suspense>around translation-heavy components while the CDN fetch resolves—useful for edge cases where cold CDN caches cause latency. - Per-component namespace granularity: Rather than layout-level fetches, fetch only the namespaces each component needs. Reduces payload size on pages with narrow translation requirements.
- Plural and formatting rules: Use
Intl.PluralRulesandIntl.NumberFormatdirectly rather than relying on library abstractions. The browser APIs are now reliable across all supported environments.
Getting Started
The patterns above work with any CDN-delivered translation source. If you want to skip the infrastructure setup and get type-safe keys, automated CDN delivery, and a translator-friendly editor out of the box, Better i18n's Next.js integration covers this stack—middleware helpers, typed key generation, and a dashboard your translation team can use without touching code.
See the developer docs for the full API reference and SDK integration guides.