0Pricing
Next.js 15 Fullstack (App Router + Server Actions) · 课时

使用中间件检测区域设置并进行子路径路由

检测用户区域设置,并在中间件中将请求重写到本地化子路径。

使用中间件检测区域设置并进行子路径路由 是 CoddyKit 上的免费 Next.js 15 Fullstack (App Router + Server Actions) 课时。 这是第 1 节课,共 4 节。 你可以在下方免费阅读本课时的完整内容 — 然后在浏览器中使用内置代码编辑器和全天候 AI 导师进行实践。 这是 Next.js 15 Fullstack (App Router + Server Actions) 学习路径的一部分,你的进度在网页和 CoddyKit 应用中同步。 Next.js 15 Fullstack (App Router + Server Actions) 课程共包含 4 节课。

本课时的部分内容尚未翻译,以英文显示。

What Is Locale Detection?

Internationalization (i18n) in Next.js 15 starts with locale detection: figuring out which language and region the visitor prefers before serving any content.

Detection typically reads from three sources, in order of priority:

  • URL subpath — /en/about, /tr/about (most explicit)
  • Cookie — a previously saved preference like NEXT_LOCALE=fr
  • Accept-Language header — sent automatically by the browser

Next.js 15 removed its built-in i18n config in the App Router era, so you implement this logic yourself inside middleware. This gives you full control over matching rules, fallbacks, and redirects.

Project Setup: Supported Locales

Before writing middleware, define your supported locales and default locale in a shared constants file. This single source of truth is imported by middleware, layout components, and any locale-aware utility.

A common convention is a lib/i18n.ts file that exports:

  • locales — the full list of supported locale strings
  • defaultLocale — the fallback when no match is found
  • A helper type for type-safe locale values
// lib/i18n.ts
export const locales = ['en', 'tr', 'de', 'fr'] as const;
export type Locale = (typeof locales)[number];
export const defaultLocale: Locale = 'en';

export function isValidLocale(value: string): value is Locale {
  return (locales as readonly string[]).includes(value);
}

Parsing the Accept-Language Header

The browser sends an Accept-Language header like tr-TR,tr;q=0.9,en;q=0.8. You need to parse this and find the best match against your supported locales.

Rather than writing a full parser, the popular @formatjs/intl-localematcher library handles weighted negotiation correctly. Pair it with negotiator to extract an ordered list from the raw header string.

Install both packages:

  • npm i @formatjs/intl-localematcher negotiator
  • npm i -D @types/negotiator
// lib/locale-detection.ts
import Negotiator from 'negotiator';
import { match } from '@formatjs/intl-localematcher';
import { locales, defaultLocale, type Locale } from './i18n';

export function getLocaleFromHeader(acceptLanguage: string | null): Locale {
  if (!acceptLanguage) return defaultLocale;

  const headers = { 'accept-language': acceptLanguage };
  const languages = new Negotiator({ headers }).languages();

  try {
    return match(languages, [...locales], defaultLocale) as Locale;
  } catch {
    return defaultLocale;
  }
}

Reading the Locale Cookie

When a user manually switches their language, you store that choice in a cookie so future visits respect it immediately — without re-reading the Accept-Language header.

In middleware you read cookies directly from the incoming NextRequest. The cookie name is conventionally NEXT_LOCALE, but you can use any name as long as you are consistent when writing it (typically done in a Server Action or API route).

The detection priority should be: cookie first, then header, then default.

// lib/locale-detection.ts  (continued)
import { type NextRequest } from 'next/server';
import { isValidLocale, defaultLocale, type Locale } from './i18n';
import { getLocaleFromHeader } from './locale-detection';

export const LOCALE_COOKIE = 'NEXT_LOCALE';

export function detectLocale(request: NextRequest): Locale {
  // 1. Check explicit cookie preference
  const cookieValue = request.cookies.get(LOCALE_COOKIE)?.value;
  if (cookieValue && isValidLocale(cookieValue)) {
    return cookieValue;
  }

  // 2. Negotiate from Accept-Language header
  const acceptLanguage = request.headers.get('accept-language');
  return getLocaleFromHeader(acceptLanguage);
}

Subpath Routing Convention

Subpath routing means every URL is prefixed with its locale: /en/dashboard, /tr/dashboard, /de/dashboard. This approach is:

  • SEO-friendly — search engines index each locale as a distinct URL
  • Shareable — a link always points to the same locale
  • Cacheable — CDN can cache per locale without cookie inspection

Your App Router folder structure mirrors this:

  • app/[lang]/page.tsx — homepage per locale
  • app/[lang]/layout.tsx — locale-aware root layout
  • app/[lang]/dashboard/page.tsx — nested routes

The [lang] dynamic segment captures the locale string from the URL.

Writing the Middleware: Core Logic

The middleware.ts file at the project root intercepts every request. Its job is:

  1. Skip requests that already have a valid locale prefix — serve them as-is
  2. For requests without a locale prefix, detect the locale and redirect to the localized URL

Use NextResponse.redirect (permanent 308 for SEO, or temporary 307 for dynamic preferences) or NextResponse.rewrite (hides the prefix from the browser's address bar — less common for subpath routing).

// middleware.ts
import { NextResponse, type NextRequest } from 'next/server';
import { locales, defaultLocale, isValidLocale } from './lib/i18n';
import { detectLocale } from './lib/locale-detection';

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

  // Check if the path already starts with a valid locale
  const pathnameLocale = locales.find(
    (locale) => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`
  );

  if (pathnameLocale) {
    // Already localized — pass through
    return NextResponse.next();
  }

  // Detect locale and redirect
  const locale = detectLocale(request);
  const newUrl = new URL(`/${locale}${pathname}`, request.url);
  newUrl.search = request.nextUrl.search; // preserve query params

  return NextResponse.redirect(newUrl, { status: 307 });
}

The matcher Config: Excluding Static Assets

Without a matcher, your middleware runs on every request — including /_next/static, /favicon.ico, and API routes. This wastes compute and can break static asset delivery.

Export a config object with a matcher array to restrict which paths middleware processes. The pattern below excludes:

  • Next.js internal paths (/_next)
  • Static file extensions (images, fonts, manifests)
  • API routes if you handle their i18n separately
// middleware.ts (add at the bottom)
export const config = {
  matcher: [
    /*
     * Match all request paths EXCEPT:
     * - _next/static  (static files)
     * - _next/image   (image optimization)
     * - favicon.ico, sitemap.xml, robots.txt
     * - Files with extensions (e.g. .png, .svg, .woff2)
     */
    '/((?!_next/static|_next/image|favicon\.ico|sitemap\.xml|robots\.txt|.*\.(?:png|jpg|jpeg|gif|svg|ico|woff|woff2|ttf|otf|webp)).*)',
  ],
};

The [lang] Layout: Passing Locale Down

Once middleware guarantees every URL has a locale prefix, the app/[lang]/layout.tsx receives params.lang as a prop. Use it to:

  • Set the lang attribute on <html> for accessibility and SEO
  • Set dir attribute for RTL languages (Arabic, Hebrew)
  • Load the correct translation dictionary

In Next.js 15, layout params are now async — you must await params before reading properties.

// app/[lang]/layout.tsx
import { type Locale, isValidLocale, defaultLocale } from '@/lib/i18n';
import { getDictionary } from '@/lib/dictionaries';

interface RootLayoutProps {
  children: React.ReactNode;
  params: Promise<{ lang: string }>;
}

export default async function RootLayout({ children, params }: RootLayoutProps) {
  const { lang } = await params;
  const locale: Locale = isValidLocale(lang) ? lang : defaultLocale;
  const dict = await getDictionary(locale);

  return (
    <html lang={locale} dir={locale === 'ar' ? 'rtl' : 'ltr'}>
      <body>{children}</body>
    </html>
  );
}

Loading Translation Dictionaries

A dictionary is a JSON file containing all translated strings for one locale. Lazy-loading them (one file per locale) keeps the initial bundle small — only the active locale's strings are fetched.

The getDictionary function uses a dynamic import() so Next.js can code-split each locale into its own chunk at build time.

// lib/dictionaries.ts
import type { Locale } from './i18n';

const dictionaries = {
  en: () => import('../dictionaries/en.json').then((m) => m.default),
  tr: () => import('../dictionaries/tr.json').then((m) => m.default),
  de: () => import('../dictionaries/de.json').then((m) => m.default),
  fr: () => import('../dictionaries/fr.json').then((m) => m.default),
};

export type Dictionary = Awaited<ReturnType<(typeof dictionaries)['en']>>;

export async function getDictionary(locale: Locale): Promise<Dictionary> {
  return dictionaries[locale]();
}

// dictionaries/en.json (example shape)
// {
//   "nav": { "home": "Home", "about": "About" },
//   "hero": { "title": "Welcome", "subtitle": "Start learning today" }
// }

Saving the User's Locale Preference via Server Action

When the user picks a different language from a switcher UI, save that choice to a cookie using a Server Action. The next request will then have the cookie available in middleware, overriding the Accept-Language header.

Use cookies() from next/headers inside the Server Action. After setting the cookie, call redirect() to navigate to the same path under the new locale.

// app/actions/set-locale.ts
'use server';

import { cookies } from 'next/headers';
import { redirect } from 'next/navigation';
import { isValidLocale, type Locale } from '@/lib/i18n';
import { LOCALE_COOKIE } from '@/lib/locale-detection';

export async function setLocaleAction(
  locale: string,
  currentPath: string
): Promise<void> {
  if (!isValidLocale(locale)) {
    throw new Error(`Unsupported locale: ${locale}`);
  }

  const cookieStore = await cookies();
  cookieStore.set(LOCALE_COOKIE, locale, {
    path: '/',
    maxAge: 60 * 60 * 24 * 365, // 1 year
    sameSite: 'lax',
  });

  // Strip existing locale prefix and redirect to new locale path
  const pathWithoutLocale = currentPath.replace(/^\/[a-z]{2}(\/|$)/, '/');
  redirect(`/${locale}${pathWithoutLocale}`);
}

Building the Language Switcher Component

The language switcher is a Client Component that calls the setLocaleAction Server Action when the user selects a new locale. It uses usePathname() to know the current URL so the action can redirect to the equivalent page in the new locale.

Note that the form approach (using a <form action={...}>) also works and is accessible — it degrades gracefully without JavaScript.

// components/language-switcher.tsx
'use client';

import { usePathname } from 'next/navigation';
import { useTransition } from 'react';
import { setLocaleAction } from '@/app/actions/set-locale';
import { locales, type Locale } from '@/lib/i18n';

const labels: Record<Locale, string> = {
  en: 'English',
  tr: 'Turkce',
  de: 'Deutsch',
  fr: 'Francais',
};

export function LanguageSwitcher({ currentLocale }: { currentLocale: Locale }) {
  const pathname = usePathname();
  const [isPending, startTransition] = useTransition();

  function handleChange(locale: Locale) {
    startTransition(() => {
      setLocaleAction(locale, pathname);
    });
  }

  return (
    <select
      value={currentLocale}
      onChange={(e) => handleChange(e.target.value as Locale)}
      disabled={isPending}
      aria-label="Select language"
    >
      {locales.map((locale) => (
        <option key={locale} value={locale}>
          {labels[locale]}
        </option>
      ))}
    </select>
  );
}

Knowledge Check: Middleware Redirect vs. Rewrite

A visitor lands on /dashboard without a locale prefix. The middleware detects their preferred locale as fr. Which behavior is most appropriate for a public-facing, SEO-optimized subpath i18n setup?

Lesson Recap: Locale Detection and Subpath Routing

In this lesson you built a complete i18n routing layer for Next.js 15 App Router:

  • Constants file (lib/i18n.ts) — single source of truth for supported locales and the default locale, with a type-safe Locale union type.
  • Detection utilities (lib/locale-detection.ts) — priority chain: cookie first, then Accept-Language header negotiation via @formatjs/intl-localematcher, then fallback to default.
  • Middleware (middleware.ts) — passes already-localized paths through, redirects bare paths to the detected locale subpath; restricted by a matcher that skips static assets.
  • App Router structure — app/[lang]/layout.tsx receives params as an awaited Promise in Next.js 15, sets html lang and loads the lazy dictionary.
  • Server Action — writes the user's explicit choice to a long-lived cookie and redirects to the equivalent localized path.
  • Language Switcher — Client Component calling the Server Action inside useTransition for a non-blocking UX.

This pattern gives you full SEO benefit (distinct URLs per locale), graceful degradation, and user preference persistence — all without any third-party i18n router library.

常见问题解答

「使用中间件检测区域设置并进行子路径路由」课时是免费的吗?

是的 — 「使用中间件检测区域设置并进行子路径路由」的完整文本可在网页上免费阅读。要进行交互式练习(内置代码编辑器和全天候 AI 导师)并解锁 Next.js 15 Fullstack (App Router + Server Actions) 课程的其余内容,请升级到 CoddyKit PRO。 Next.js 15 Fullstack (App Router + Server Actions) 课程共包含 4 节课。

「使用中间件检测区域设置并进行子路径路由」这节课中我会学到什么?

检测用户区域设置,并在中间件中将请求重写到本地化子路径。 你通过在浏览器中直接运行的动手代码来练习 Next.js 15 Fullstack (App Router + Server Actions),全天候 AI 导师会在你学习这节课的过程中回答你的问题。

学习 Next.js 15 Fullstack (App Router + Server Actions) 需要有经验吗?

无需任何先前经验。CoddyKit 上的 Next.js 15 Fullstack (App Router + Server Actions) 课程适合初学者到高级学习者,你可以从这里开始或从头开始,按照自己的节奏学习。 这是第 1 节课,共 4 节。

「使用中间件检测区域设置并进行子路径路由」课时需要多长时间?

大多数 CoddyKit 课程大约需要 5–10 分钟。每节课都很精短且互动,所以你能稳步进步,并在网页和应用中从离开的地方继续。

我能在这节 Next.js 15 Fullstack (App Router + Server Actions) 课中编写并运行代码吗?

能。每节 Next.js 15 Fullstack (App Router + Server Actions) 课都包含内置代码编辑器,你可以在浏览器中直接编写并运行真实代码,并获得即时 AI 反馈 — 无需本地设置。

此课程中的所有课时

  1. 使用中间件检测区域设置并进行子路径路由
  2. 服务端翻译加载与消息目录
  3. 本地化元数据、站点地图与 hreflang
  4. 按区域设置格式化日期、数字与复数
← 返回 Next.js 15 Fullstack (App Router + Server Actions)