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:
| Approach | Chinese | English |
|---|---|---|
| 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:
- Cookie (user has manually switched languages before)
- Accept-Language header (browser preference)
- 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.