미들웨어를 활용한 로케일 감지와 하위 경로 라우팅
사용자 로케일을 감지하고 미들웨어에서 요청을 지역화된 하위 경로로 다시 작성하는 방법을 배웁니다.
미들웨어를 활용한 로케일 감지와 하위 경로 라우팅은(는) CoddyKit의 무료 Next.js 15 Fullstack (App Router + Server Actions) 강의입니다. 이것은 4개 중 1번째 강의입니다. 아래에서 전체 강의를 무료로 읽을 수 있으며, 내장 코드 에디터와 24/7 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 stringsdefaultLocale— 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 negotiatornpm 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 localeapp/[lang]/layout.tsx— locale-aware root layoutapp/[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:
- Skip requests that already have a valid locale prefix — serve them as-is
- 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
langattribute on<html>for accessibility and SEO - Set
dirattribute 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-safeLocaleunion type. - Detection utilities (
lib/locale-detection.ts) — priority chain: cookie first, thenAccept-Languageheader 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 amatcherthat skips static assets. - App Router structure —
app/[lang]/layout.tsxreceivesparamsas an awaited Promise in Next.js 15, setshtml langand 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
useTransitionfor 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.
자주 묻는 질문
“미들웨어를 활용한 로케일 감지와 하위 경로 라우팅” 강의는 무료인가요?
네 — “미들웨어를 활용한 로케일 감지와 하위 경로 라우팅” 전체 내용을 이 웹사이트에서 무료로 읽을 수 있습니다. 인터랙티브하게 실습하려면(내장 코드 에디터와 24/7 AI 튜터), CoddyKit PRO로 업그레이드하면 Next.js 15 Fullstack (App Router + Server Actions) 강의 전체를 잠금 해제할 수 있습니다. Next.js 15 Fullstack (App Router + Server Actions) 강의에는 총 4개의 강의가 포함되어 있습니다.
“미들웨어를 활용한 로케일 감지와 하위 경로 라우팅”에서 뭘 배우나요?
사용자 로케일을 감지하고 미들웨어에서 요청을 지역화된 하위 경로로 다시 작성하는 방법을 배웁니다. 브라우저에서 직접 실행하는 실습 코드로 Next.js 15 Fullstack (App Router + Server Actions)을(를) 배우며, 24/7 AI 튜터가 강의를 진행하면서 질문에 답변해줍니다.
Next.js 15 Fullstack (App Router + Server Actions)을(를) 시작하는 데 경험이 필요한가요?
사전 경험은 필요하지 않습니다. CoddyKit의 Next.js 15 Fullstack (App Router + Server Actions)은(는) 초급자부터 고급 학습자까지를 위해 구성되어 있으므로, 여기서 시작하거나 처음부터 시작할 수 있으며 자신의 속도대로 진행할 수 있습니다. 이것은 4개 중 1번째 강의입니다.
“미들웨어를 활용한 로케일 감지와 하위 경로 라우팅” 강의는 얼마나 걸리나요?
대부분의 CoddyKit 강의는 약 5~10분이 소요됩니다. 각 강의는 간결하고 인터랙티브하여 꾸준한 진행이 가능하며, 웹과 앱에서 중단한 부분부터 바로 시작할 수 있습니다.
이 Next.js 15 Fullstack (App Router + Server Actions) 강의에서 코드를 작성하고 실행할 수 있나요?
네. 모든 Next.js 15 Fullstack (App Router + Server Actions) 강의에는 내장 코드 에디터가 포함되어 있으므로, 브라우저에서 바로 실제 코드를 작성하고 실행한 후 즉시 AI 피드백을 받을 수 있습니다 — 로컬 설정이 필요 없습니다.
이 강의의 모든 강의
- 미들웨어를 활용한 로케일 감지와 하위 경로 라우팅
- 서버 측 번역 로딩과 메시지 카탈로그
- 지역화된 메타데이터, 사이트맵과 hreflang
- 로케일별 날짜, 숫자와 복수형 서식 지정