
Next.js 16 + i18next: Cookie-based i18n Without Changing a Single Route
A practical guide to adding cookie-based internationalization to a Next.js 16 App Router project using i18next — zero route restructuring, zero URL prefixes, full TypeScript support.
Most i18n guides for Next.js assume you want URL-based locale routing: /en/about, /zh/about, etc. That means wrapping your entire app in a [locale] dynamic segment, rewriting every page file, and potentially breaking existing links.
But what if you just want a language toggle on your personal blog? Same URLs, language stored in a cookie, no SEO complexity. That's exactly what this guide covers.
The Approach: Cookie as Single Source of Truth
Instead of URL prefixes, the user's language preference lives in a single i18n_lang cookie. Every layer — proxy, server, client — reads from and writes to the same cookie. No custom headers, no duplicated state.
First Visit (no cookie)
┌──────────────────────────────────────────────────────┐
│ proxy.ts │
│ detectLocale() │
│ → cookie absent → Accept-Language → "zh" │
│ → persisted: false │
│ withLocale() │
│ → Set-Cookie: i18n_lang=zh ◄── write-back │
└──────────────────────────┬───────────────────────────┘
│ cookie now exists
┌─────────────────┼─────────────────┐
▼ ▼
┌──────────────────┐ ┌───────────────────┐
│ server.ts │ │ client.ts │
│ getLang() │ │ LanguageDetector │
│ cookies().get() │ │ order: │
│ → "zh" │ │ ['cookie', │
│ getT('about') │ │ 'navigator'] │
│ → Chinese text │ │ → "zh" (cookie) │
└──────────────────┘ └───────────────────┘
Language Switch
┌──────────────────────────────────────────────────────┐
│ ToggleLanguage.tsx │
│ switchLocaleAction('zh') // Server Action │
│ → cookies().set('i18n_lang', 'zh') │
│ i18n.changeLanguage('zh') // Client-side immediate │
│ router.refresh() // RSC re-render │
│ → server.ts getLang() → "zh" │
└──────────────────────────────────────────────────────┘Key principles:
- The cookie is the only language carrier — no custom headers
- Server reads the cookie directly via
cookies() - Client reads the cookie via
LanguageDetector - Language switch uses a Server Action (not
document.cookie) - The proxy writes the cookie back on first visits to guarantee consistency
- URLs never change —
/blogis always/blog
Cookie-based vs. URL-based
The trade-off is straightforward:
- URL-based (
/en/about,/zh/about): works with search engines (hreflang, separate indexed pages), but requires[locale]directory restructuring, middleware guards, and broken existing links. - Cookie-based: requires zero routing changes, one URL for all languages, but search engines see only one language version. No
hreflangtags possible.
For a personal blog or internal tool this is rarely an issue.
Prerequisites
- Next.js 16 with App Router (uses
proxy.tsinstead ofmiddleware.ts) - React 19
- TypeScript
Install Dependencies
pnpm add i18next react-i18next i18next-resources-to-backend \
i18next-browser-languagedetector accept-language| Package | Purpose |
|---|---|
i18next | Core translation engine |
react-i18next | React bindings — useTranslation hook |
i18next-resources-to-backend | Lazy-load JSON translation files via dynamic import() |
i18next-browser-languagedetector | Auto-detect language from cookie / browser on the client |
accept-language | Parse Accept-Language header in proxy |
Shared Configuration Hub
settings.ts is the single source of truth imported by every other i18n file. It defines constants, types, a shared resourcesToBackend instance, and utility functions.
import type { CustomTypeOptions, InitOptions } from 'i18next'
import resourcesToBackend from 'i18next-resources-to-backend'
export const LANGUAGES = ['en', 'zh'] as const
export type Language = (typeof LANGUAGES)[number]
export type Namespace = keyof CustomTypeOptions['resources']
export const DEFAULT_LNG: Language = 'en'
export const DEFAULT_NS: Namespace = 'common'
export const LANGUAGE_COOKIE = 'i18n_lang'
// Shared backend — server.ts and client.ts reuse the same instance
export const backend = resourcesToBackend(
(locale: string, namespace: string) =>
import(`@/i18n/language/${locale}/${namespace}.json`)
)
// Type guard — validates unknown input at runtime
export function isValidLocale(value: unknown): value is Language {
return LANGUAGES.includes(value as Language)
}
// Unified i18next init options — server and client share the same config
export function getI18nOptions(
lng: Language = DEFAULT_LNG,
ns: Namespace = DEFAULT_NS
): InitOptions {
return {
lng,
fallbackLng: DEFAULT_LNG,
supportedLngs: LANGUAGES as unknown as string[],
ns,
fallbackNS: DEFAULT_NS,
defaultNS: DEFAULT_NS,
interpolation: { escapeValue: false },
returnNull: false // Missing keys → show the key string, not blank
}
}Design decisions packed into this file:
- Shared
backend: A singleresourcesToBackendinstance, exported once.server.tsandclient.tsboth import it — no duplicated instantiation. isValidLocale(): A TypeScript type guard (value is Language) that validates runtime input in the proxy and the Server Action.returnNull: false: When a translation key is missing, i18next returns the key string itself (e.g."nav.missingKey") rather thannull. This prevents blank UI areas and makes untranslated keys visible during development.Namespacetype: Derived fromkeyof CustomTypeOptions['resources']— when you add a namespace toi18next.d.ts, the type automatically includes it everywheregetT<N>()oruseTranslation()is called.
Type Safety
TypeScript module augmentation enables IDE autocompletion and compile-time validation of all translation keys.
import 'i18next'
declare module 'i18next' {
interface CustomTypeOptions {
defaultNS: 'common'
resources: {
common: typeof import('@/i18n/language/en/common.json')
home: typeof import('@/i18n/language/en/home.json')
about: typeof import('@/i18n/language/en/about.json')
blog: typeof import('@/i18n/language/en/blog.json')
project: typeof import('@/i18n/language/en/project.json')
message: typeof import('@/i18n/language/en/message.json')
poems: typeof import('@/i18n/language/en/poems.json')
photos: typeof import('@/i18n/language/en/photos.json')
use: typeof import('@/i18n/language/en/use.json')
auth: typeof import('@/i18n/language/en/auth.json')
}
}
}Adding a new namespace — two steps:
- Create
src/i18n/language/en/{name}.jsonandsrc/i18n/language/zh/{name}.json - Add one
typeof import(...)line ini18next.d.ts
No manual union type updates. The Namespace type in settings.ts picks it up automatically.
Translation File Structure
Organize translations by namespace — one JSON file per page or feature area. This enables code-splitting: each page only loads the translations it needs.
src/i18n/language/
├── en/
│ ├── common.json # Nav, footer, search, 404, shared UI
│ ├── home.json # Home page
│ ├── about.json # About page
│ ├── blog.json # Blog listing
│ └── ...
└── zh/
├── common.json
├── home.json
├── about.json
├── blog.json
└── ...{
"nav": {
"blog": "Blog",
"project": "Project",
"about": "About"
},
"footer": {
"copyright": "© 2025-present King3. All Rights Reserved."
},
"localeSwitcher": {
"label": "Switch Language"
}
}{
"nav": {
"blog": "博客",
"project": "项目",
"about": "关于"
},
"footer": {
"copyright": "© 2025-至今 King3. 保留所有权利。"
},
"localeSwitcher": {
"label": "切换语言"
}
}proxy.ts — Language Detection and Cookie Write-Back
In Next.js 16, proxy.ts replaces the old middleware.ts. It is the first touchpoint for every matched request.
Our proxy does three things: detect the language, persist it via cookie, and guard admin routes.
The Key Insight: persisted Flag
Every i18n middleware needs to answer: what language should this request use? But there's a subtler question: was this language already saved, or did we just infer it?
If the user already has a cookie → use it, do nothing else.
If the user has no cookie → infer from Accept-Language, then write the cookie back so the server and client both see the same value on this very first request.
Without this write-back, the proxy would detect zh from Accept-Language, but server.ts (reading cookies()) would find nothing and fall back to en — causing a language mismatch on the first page load.
import type { NextRequest } from 'next/server'
import type { Language } from '@/i18n/settings'
import acceptLanguage from 'accept-language'
import { NextResponse } from 'next/server'
import {
DEFAULT_LNG,
isValidLocale,
LANGUAGE_COOKIE,
LANGUAGES
} from '@/i18n/settings'
import { ADMIN_USER_ROLE } from '@/lib/auth'
import { getServerSession, requireServerAdminSession } from '@/lib/auth-session'
acceptLanguage.languages([...LANGUAGES])
// ── Locale Detection ──
function detectLocale(request: NextRequest): {
locale: Language
persisted: boolean
} {
// 1. Cookie takes priority
const saved = request.cookies.get(LANGUAGE_COOKIE)?.value
if (saved && LANGUAGES.includes(saved as Language)) {
return { locale: saved as Language, persisted: true }
}
// 2. Fall back to Accept-Language header
const matched = acceptLanguage.get(request.headers.get('Accept-Language'))
return {
locale: isValidLocale(matched) ? matched : DEFAULT_LNG,
persisted: false // ← Needs write-back
}
}
function withLocale(
response: NextResponse,
locale: Language,
persisted: boolean
) {
if (!persisted) {
response.cookies.set(LANGUAGE_COOKIE, locale, {
path: '/',
maxAge: 60 * 60 * 24 * 7, // 7 days
sameSite: 'lax'
})
}
return response
}
// ── Proxy ──
export async function proxy(request: NextRequest) {
const { pathname } = request.nextUrl
const { locale, persisted } = detectLocale(request)
const reply = (response: NextResponse) =>
withLocale(response, locale, persisted)
const redirect = (path: string) =>
reply(NextResponse.redirect(new URL(path, request.url)))
// Admin auth guard
if (pathname.startsWith('/admin')) {
try {
await requireServerAdminSession(request.headers)
} catch {
return redirect('/')
}
}
// Auth page: redirect if already signed in
if (pathname === '/auth') {
const session = await getServerSession(request.headers)
if (session) {
const redirectPath =
session.user.role === ADMIN_USER_ROLE ? '/admin' : '/'
return redirect(redirectPath)
}
}
return reply(NextResponse.next())
}
export const config = {
matcher: [
'/((?!api|_next/static|_next/image|favicon.ico|icons|images|fonts).*)'
]
}Language precedence: Cookie → Accept-Language header → fallback en.
Why persisted matters: On a first visit, the proxy detects zh from the browser's Accept-Language. Without writing the cookie, server.ts's getLang() (which reads cookies()) would find nothing and return en. The proxy would use zh but server-rendered content would be en — a mismatch on the very first page load. The write-back eliminates this race entirely.
Unlike many i18n middleware implementations, this one does not inject a custom x-i18n-lang header. The cookie is the only carrier — server components read it directly.
Server-side Translation — getT()
Server Components can't use React hooks, so we need an async utility. Every call creates a fresh i18next instance.
Why Per-Request Instance Creation?
Next.js App Router handles multiple concurrent requests on the server. If two requests arrive simultaneously — one from a Chinese user, one from an English user — and both share a singleton i18next instance, the instance's internal language state would be overwritten by whichever request runs last. This is a classic shared-mutable-state bug in concurrent server environments.
createInstance() gives each request its own isolated i18next instance. The overhead is negligible — i18next instances are lightweight, and the expensive part (loading translation JSON) is already cached by the module system.
'use server'
import type { Language, Namespace } from './settings'
import { createInstance } from 'i18next'
import { cookies } from 'next/headers'
import { initReactI18next } from 'react-i18next/initReactI18next'
import {
backend,
DEFAULT_NS,
DEFAULT_LNG,
getI18nOptions,
LANGUAGE_COOKIE
} from './settings'
async function initI18next(lng: Language, ns: Namespace) {
const instance = createInstance()
instance.use(initReactI18next)
instance.use(backend) // Shared backend from settings.ts
await instance.init(getI18nOptions(lng, ns))
return instance
}
/**
* Get translation function for Server Components / generateMetadata.
*
* @example
* const { t, lang } = await getT('about')
* return <h1>{t('title')}</h1>
*/
export async function getT<N extends Namespace = typeof DEFAULT_NS>(
ns: N = typeof DEFAULT_NS as N
) {
const lang = await getLang()
const i18next = await initI18next(lang, ns)
return {
t: i18next.getFixedT(lang, ns),
lang,
i18next
}
}
// Utility — know the current language without a full i18next instance.
// Useful for generateMetadata or conditional server logic.
export async function getLang() {
const cookie = await cookies()
const lang = (cookie.get(LANGUAGE_COOKIE)?.value ?? DEFAULT_LNG) as Language
return lang
}Key points:
- Reads the cookie directly via
cookies(), not a custom header. The proxy already wrote the cookie — server just reads it. getLang()is exposed separately sogenerateMetadataand other server utilities can know the language without spinning up an i18next instance.- Each
getT()call creates an isolated i18next instance viacreateInstance()— no cross-request contamination.
Root Layout — Dynamic lang Attribute
Both generateMetadata() and RootLayout call getLang() to set the HTML lang attribute and translated metadata:
import type { Metadata } from 'next'
import { getLang, getT } from '@/i18n/server'
export async function generateMetadata(): Promise<Metadata> {
const { t } = await getT('common')
return {
title: {
default: 'King3',
template: '%s - King3'
},
description: t('metadata.root.description'),
icons: { icon: '/icons/favicon.svg' }
}
}
export default async function RootLayout({
children
}: {
children: React.ReactNode
}) {
const lang = await getLang()
return (
<html lang={lang} suppressHydrationWarning>
<body className="min-h-screen scroll-smooth antialiased">
{/* Providers ... */}
{children}
</body>
</html>
)
}<html lang> and <meta description> are synchronized because both derive from the same cookie.
Client-side Initialization
Client Components use a global i18next singleton with LanguageDetector to auto-detect the language from the cookie.
'use client'
import i18next from 'i18next'
import LanguageDetector from 'i18next-browser-languagedetector'
import {
initReactI18next,
useTranslation as useI18nextTranslation
} from 'react-i18next'
import { backend, getI18nOptions, LANGUAGE_COOKIE, LANGUAGES } from './settings'
const runsOnServer = typeof window === 'undefined'
i18next
.use(initReactI18next)
.use(LanguageDetector)
.use(backend) // Shared backend from settings.ts
.init({
...getI18nOptions(),
lng: undefined, // Let LanguageDetector decide — don't hardcode
detection: {
order: ['cookie', 'navigator'],
lookupCookie: LANGUAGE_COOKIE,
caches: ['cookie'] // Cache detected result back to cookie
},
// SSR: preload all languages so hydration output matches.
// CSR: leave empty — languages load dynamically on demand.
preload: runsOnServer ? (LANGUAGES as unknown as string[]) : []
})
const useTranslation = useI18nextTranslation
export { useTranslation }
export default i18nextDetection order:
- Cookie (
i18n_lang) — set by the proxy on first visit, or by the language switcher - Navigator (
navigator.language) — browser's preferred language as ultimate fallback
lng: undefined is crucial — it tells i18next to rely entirely on the LanguageDetector. Setting a hardcoded value would override the detection chain.
useTranslation alias ensures every component imports from one path (@/i18n/client) regardless of singleton initialization state.
Language Switcher — Server Action + Client Update + RSC Refresh
The language switcher orchestrates three things: persist the preference server-side (Server Action), update client components immediately (i18n API), and re-render server components (router.refresh).
Why Server Action Instead of document.cookie?
document.cookie is synchronous, string-manipulation heavy, and operates entirely client-side. It can race with Next.js's own cookie handling in the App Router. A Server Action writes the cookie via the cookies() API — the same API the proxy and server components use — guaranteeing consistency. It also enables input validation (isValidLocale()) and structured error handling (ResponseResult).
'use server'
import { cookies } from 'next/headers'
import { isValidLocale, LANGUAGE_COOKIE } from '@/i18n/settings'
import { failure, success } from '@/lib/result'
export async function switchLocaleAction(value: unknown) {
if (!isValidLocale(value)) {
return failure(new Error(`Unsupported locale: "${value}"`))
}
try {
const cookie = await cookies()
cookie.set(LANGUAGE_COOKIE, value, {
path: '/',
maxAge: 60 * 60 * 24 * 7,
sameSite: 'lax'
})
return success(null)
} catch (error: unknown) {
return failure(error)
}
}Why value: unknown instead of value: Language? Server Actions can be called from anywhere — including fetch() requests and third-party scripts. TypeScript types are erased at runtime. The unknown parameter + isValidLocale() type guard ensures the function validates at runtime, not just compile time.
The cookie parameters (path, maxAge, sameSite) match exactly what the proxy's withLocale() sets — consistency across all cookie writes.
'use client'
import type { Language } from '@/i18n/settings'
import { useRouter } from 'next/navigation'
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
import { switchLocaleAction } from '@/app/actions/i18n'
import { InteractiveIcon, LanguageIcon } from '@/components/icons'
import { useTranslation } from '@/i18n/client'
function ToggleLanguage() {
const router = useRouter()
const { i18n, t } = useTranslation()
const currentLang = (i18n.language ?? 'en') as Language
const [mounted, setMounted] = useState(false)
useEffect(() => setMounted(true), [])
const switchLang = async (event: React.MouseEvent) => {
event.preventDefault()
const lang: Language = currentLang === 'en' ? 'zh' : 'en'
// 1. Server Action: persist to cookie server-side
const result = await switchLocaleAction(lang)
// 2. Client i18next: update immediately (no waiting for re-render)
i18n.changeLanguage(lang)
// 3. RSC refresh: server components re-render with new cookie
if (result.success) {
router.refresh()
} else {
toast.error(result.message)
}
}
// SSR phase: render a static icon to avoid hydration mismatch
if (!mounted) {
return (
<InteractiveIcon trigger="hover">
{() => <LanguageIcon isEN isHovered={false} />}
</InteractiveIcon>
)
}
const isEN = currentLang === 'en'
return (
<InteractiveIcon
alt={t('localeSwitcher.label')}
trigger="hover"
onClick={switchLang}
>
{({ isHovered }) => <LanguageIcon isEN={isEN} isHovered={isHovered} />}
</InteractiveIcon>
)
}The three-step switch:
- Server Action — persist the choice server-side via
cookies().set() i18n.changeLanguage()— client components update immediatelyrouter.refresh()— RSC tree re-renders,server.tsreads the new cookie
SSR safety (mounted guard): During SSR, i18n.language is not yet resolved (no cookie exists in the Node environment on first render). Without the guard, the server would render isEN = true while the client might detect zh, causing a hydration mismatch. The mounted state renders a fixed isEN placeholder during SSR, then the correct language-dependent icon after hydration.
Error handling: The Server Action returns a ResponseResult. If cookie write fails, toast.error() shows a user-visible message — the switch doesn't silently fail.
Using Translations
In Server Components:
import { getT } from '@/i18n/server'
export default async function Home() {
const { t } = await getT('home')
return (
<div>
<h2>{t('latestUpdates')}</h2>
{/* ... */}
</div>
)
}In Client Components:
'use client'
import { useTranslation } from '@/i18n/client'
function ErrorComponent() {
const { t } = useTranslation('common')
return <h1>{t('error.title', 'Something went wrong')}</h1>
}In generateMetadata:
import type { Metadata } from 'next'
import { getT } from '@/i18n/server'
export async function generateMetadata(): Promise<Metadata> {
const { t } = await getT('about')
return {
title: t('title'),
description: t('description')
}
}Quick reference:
| Context | Usage |
|---|---|
| Server Component | const { t } = await getT('ns') |
| Client Component | const { t } = useTranslation('ns') |
generateMetadata | const { t } = await getT('ns') |
| Server Action | const { t } = await getT('ns') |
| Just need language | const lang = await getLang() |
Hydration Consistency
A common pitfall with i18n is hydration mismatch — the server renders one language but the client detects another. In this setup, both sides derive from the same cookie:
- Server:
getLang()readsi18n_langcookie viacookies() - Client:
LanguageDetectorreadsi18n_langcookie withorder: ['cookie', 'navigator']
Since both derive from the same cookie value, they always agree — no custom headers, no separate state channels, no mismatch.
First Visit Walkthrough
When a user visits for the first time (no i18n_lang cookie):
- proxy.ts:
detectLocale()finds no cookie → parsesAccept-Language→ matcheszh→persisted: false - proxy.ts:
withLocale()writesSet-Cookie: i18n_lang=zhto the response - RSC:
getLang()readscookies()→ findszh→ renders Chinese content - Client hydration:
LanguageDetectorreads cookie → findszh→ initializes in Chinese - Result: the first page load is fully Chinese, from server render through client hydration
Without step 2 (the write-back), step 3 would find no cookie and fall back to en, while step 4 might independently detect zh from navigator.language — producing a server-en / client-zh mismatch on the very first load.
Lazy Loading
The i18next-resources-to-backend plugin uses dynamic import() to load translation files on demand:
- Visit home → loads
common.json+home.json - Visit about → loads
common.json+about.json - Translations for pages you haven't visited are never downloaded
Since server.ts and client.ts share the same backend instance from settings.ts, the lazy-loading behavior is consistent across both environments.
Summary
src/
├── i18n/
│ ├── settings.ts # Shared config, backend, type guard, init options
│ ├── server.ts # getT() + getLang() for Server Components
│ └── client.ts # useTranslation for Client Components
├── i18n/language/
│ ├── en/{namespace}.json # English translations (10 namespaces)
│ └── zh/{namespace}.json # Chinese translations (10 namespaces)
├── types/
│ └── i18next.d.ts # TypeScript module augmentation
├── app/actions/
│ └── i18n.ts # switchLocaleAction — Server Action for cookie write
├── components/layout/Header/
│ └── ToggleLanguage.tsx # Language switch UI
└── proxy.ts # detectLocale + withLocale + persisted flagWhat this architecture gives you:
- Zero routing changes — no
[locale]segments, no URL restructuring - Cookie as the single source of truth — no custom headers, no duplicated state
- First-visit consistency — the proxy writes the cookie back so server and client agree from the very first request
- Per-request isolation —
createInstance()prevents cross-request language contamination on the server - Reliable language switching — Server Action for cookie persistence, with error handling
- Full TypeScript support — compile-time validation of all translation keys
- Lazy loading — only the current page's translations are downloaded