Tutorials

Next.js App Router i18n: Server Components and RSC Patterns for 2026

Eray Gündoğmuş
Eray Gündoğmuş
·10 min read
Share
Next.js App Router i18n: Server Components and RSC Patterns for 2026

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.PluralRules and Intl.NumberFormat directly 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.