Cookie-based Language Switch with SSR in Next.js App Router

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.

The Problem

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.

The Solution

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.

Implementation

1. Types and Constants

// 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';

2. Middleware

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|.*\\..*$).*)'],
};

3. Root Layout

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>
  );
}

4. Language Provider

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>
  );
};

5. Usage in Components

'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>
  );
};

Why This Works

  1. Cookie persists across browser sessions (1 year expiry)
  2. Middleware bridges cookie to SSR (cookies can't be read directly in Server Components)
  3. Header passes language to the root layout
  4. SSR renders with correct language immediately
  5. No flash because server know what language to render
  6. Toggle updates both React state and cookie for next request

Benefits

Source

This pattern is used in the Middle Way Society website. Source code: GitHub