Back to Blog
Next.jsi18nMiddleware

Implementing i18n Routing with Next.js Middleware

Use Next.js Middleware with Cookie and Accept-Language to build smart multilingual routing

Author: ekent·Published on January 17, 2026

When your website needs to support multiple languages, URL structure is the first decision to make. We went with "no prefix for the default language, prefix for others" and implemented automatic language detection using Next.js Middleware. Here's how it works.

URL Structure Design

There are two common approaches for multilingual URLs:

ApproachChineseEnglish
Prefix all languages/zh/blog/en/blog
No prefix for default/blog/en/blog

We chose the second one. The reasoning is practical: our primary audience speaks Chinese, so most traffic doesn't need an extra /zh prefix, and it's better for SEO.

The corresponding App Router directory structure:

src/app/
├── (zh)/           # Route group, Chinese pages (no prefix)
│   ├── blog/
│   └── page.tsx
├── en/             # English pages (/en prefix)
│   ├── blog/
│   └── page.tsx
└── layout.tsx

(zh) uses Next.js route group syntax—the parentheses mean this directory won't appear in the URL.

Language Detection Logic

Middleware runs before every request reaches a page, making it the ideal place for language routing. The detection priority is:

  1. Cookie (user has manually switched languages before)
  2. Accept-Language header (browser preference)
  3. Default language (fallback to Chinese)
// middleware.ts
import { NextRequest, NextResponse } from 'next/server';

const defaultLocale = 'zh';
const locales = ['zh', 'en'];

function getLocale(request: NextRequest): string {
  // Cookie takes priority
  const cookieLocale = request.cookies.get('locale')?.value;
  if (cookieLocale && locales.includes(cookieLocale)) {
    return cookieLocale;
  }

  // Fall back to Accept-Language
  const acceptLang = request.headers.get('Accept-Language') || '';
  for (const locale of locales) {
    if (acceptLang.includes(locale)) {
      return locale;
    }
  }

  return defaultLocale;
}

Routing and Redirects

Once the language is detected, the middleware decides whether to redirect:

export function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl;

  // Skip static assets, API routes, admin pages
  if (
    pathname.startsWith('/api') ||
    pathname.startsWith('/_next') ||
    pathname.includes('.')
  ) {
    return NextResponse.next();
  }

  // Check if URL already has a locale prefix
  const hasLocalePrefix = locales.some(
    (l) => pathname.startsWith(`/${l}/`) || pathname === `/${l}`
  );

  if (hasLocalePrefix) return NextResponse.next();

  // No prefix—detect language and decide
  const locale = getLocale(request);

  if (locale === defaultLocale) {
    // Default language: no prefix needed
    return NextResponse.next();
  }

  // Non-default language: redirect to prefixed path
  const url = request.nextUrl.clone();
  url.pathname = `/${locale}${pathname}`;
  return NextResponse.redirect(url);
}

The core logic: a Chinese user visiting /blog passes through directly; an English user visiting /blog gets 302-redirected to /en/blog.

Language Switcher

When a user manually switches languages, the frontend does two things: set a cookie and navigate.

function switchLocale(newLocale: string) {
  document.cookie = `locale=${newLocale}; path=/; max-age=31536000`;

  const currentPath = window.location.pathname;

  if (newLocale === 'zh') {
    // Switch to Chinese: remove /en prefix
    const path = currentPath.replace(/^\/en/, '') || '/';
    window.location.href = path;
  } else {
    // Switch to English: add /en prefix
    const cleanPath = currentPath.replace(/^\/en/, '');
    window.location.href = `/en${cleanPath}`;
  }
}

The cookie's max-age is set to one year. On subsequent visits, the middleware reads the cookie first, so the user's preference is remembered.

Common Pitfalls

1. Static assets caught by middleware. Forgetting to exclude .js, .css, and image paths triggers language detection on every resource request, hurting performance. Use pathname.includes('.') as a simple filter, or configure a matcher for precise control.

2. SEO hreflang tags. Search engines need to know about alternate language versions. Add these to your <head>:

<link rel="alternate" hreflang="zh" href="https://example.com/blog" />
<link rel="alternate" hreflang="en" href="https://example.com/en/blog" />

3. Sitemap coverage. Every page should appear in the sitemap in both languages to help search engines discover and index all versions.

Takeaways

Next.js Middleware's pre-request interception is a natural fit for multilingual routing. The core design is: Cookie first → Accept-Language fallback → no prefix for the default language. This approach is SEO-friendly and feels natural to users—first visit matches their browser language, and manual switches are remembered permanently.