When building a multilingual site with Next.js App Router, you need language preference to persist across sessions AND render correctly on the server. Using localStorage alone causes a flash of wrong language on page load. Here's a clean solution using cookies and middleware.
Server Components can't read localStorage. If you store language preference in localStorage and read it on the client, the server do know the languague to render and can only render null (or the wrong default language). This causes a visible flash.
Use cookies for storage and middleware to bridge the gap:
Request → Middleware (reads cookie) → Header → Root Layout (SSR) → LanguageProvider → Components
The middleware reads the cookie and passes the language via a custom header to the server-side rendering.
// src/components/language/types.ts
export type Language = 'en' | 'zh';
export const LANGUAGE_COOKIE_KEY = 'middleway-language';
export const LANGUAGE_HEADER_KEY = 'x-middleway-language';
The middleware runs on every request, reads the cookie, and sets a header:
// src/middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { LANGUAGE_COOKIE_KEY, LANGUAGE_HEADER_KEY } from './components/language/types';
export const middleware = (request: NextRequest) => {
const response = NextResponse.next();
const languageCookie = request.cookies.get(LANGUAGE_COOKIE_KEY);
const lang = languageCookie?.value === 'zh' ? 'zh' : 'en';
response.headers.set(LANGUAGE_HEADER_KEY, lang);
return response;
};
export const config = {
matcher: ['/((?!api|_next/static|_next/image|favicon.ico|.*\\..*$).*)'],
};
The layout reads the header and passes the initial language to the provider:
// src/app/layout.tsx
import { headers } from 'next/headers';
import { LanguageProvider } from '@/components/language/LanguageProvider';
import type { Language } from '@/components/language/types';
import { LANGUAGE_HEADER_KEY } from '@/components/language/types';
export default async function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
const headersList = await headers();
const initialLang = (headersList.get(LANGUAGE_HEADER_KEY) || 'en') as Language;
return (
<html lang={initialLang}>
<body>
<LanguageProvider initialLang={initialLang}>
{children}
</LanguageProvider>
</body>
</html>
);
}
The provider manages state and exposes a hook:
// src/components/language/LanguageProvider.tsx
'use client';
import { createContext, useContext, useState, ReactNode } from 'react';
import type { Language } from './types';
import { LANGUAGE_COOKIE_KEY } from './types';
type LanguageContextValue = {
lang: Language;
setLang: (lang: Language) => void;
};
const LanguageContext = createContext<LanguageContextValue>({
lang: 'en',
setLang: () => {},
});
export const useLanguage = () => useContext(LanguageContext);
export const LanguageProvider = ({
children,
initialLang,
}: {
children: ReactNode;
initialLang: Language;
}) => {
const [lang, setLangState] = useState<Language>(initialLang);
const setLang = (newLang: Language) => {
setLangState(newLang);
document.cookie = `${LANGUAGE_COOKIE_KEY}=${newLang};path=/;max-age=31536000;SameSite=Lax`;
};
return (
<LanguageContext.Provider value={{ lang, setLang }}>
{children}
</LanguageContext.Provider>
);
};
'use client';
import { useLanguage } from '@/components/language/LanguageProvider';
const translations = {
en: { greeting: 'Hello' },
zh: { greeting: '你好' },
};
export const MyComponent = () => {
const { lang, setLang } = useLanguage();
const t = translations[lang];
const toggleLanguage = () => setLang(lang === 'en' ? 'zh' : 'en');
return (
<div>
<p>{t.greeting}</p>
<button onClick={toggleLanguage}>
{lang === 'en' ? '中文' : 'English'}
</button>
</div>
);
};
<html lang> attributeThis pattern is used in the Middle Way Society website. Source code: GitHub