Next.js App Router i18n Middleware Configuration

A correctly scoped middleware.ts matcher and a redirect-once locale rule are the two things that keep Next.js App Router i18n from either skipping static assets or spinning into ERR_TOO_MANY_REDIRECTS. The App Router does not read the i18n key in next.config.js — that key is Pages Router-only — so locale prefixing, cookie persistence, and the x-locale header all have to be implemented by hand in middleware that runs at the edge before any React Server Component renders. This page covers the exact matcher, the redirect-versus-rewrite decision for the locale prefix, the guard that prevents redirect loops, and how to stamp a NEXT_LOCALE cookie plus an x-locale request header so downstream layouts can read the resolved locale without re-parsing the URL.

The work fits inside the broader Next.js i18n Routing Setup cluster, and the locale it resolves should feed the same fallback chain your other framework layers use.

Next.js i18n middleware request flow An incoming request is first filtered by the matcher, then checked for an existing locale prefix. Prefixed requests get an x-locale header and pass through; bare paths are redirected once to the prefixed URL with a NEXT_LOCALE cookie. Request /about matcher skip _next / api has locale prefix? /en/... /de/... NextResponse.next() set x-locale header redirect once /de/about + cookie yes no
The locale-prefix guard is the loop breaker: only bare paths redirect, prefixed paths pass through.

Root cause: why the matcher and redirect rule fail

Two failure modes dominate App Router i18n middleware, and both come from a single misconfiguration.

The first is an over-broad matcher. If the matcher does not exclude _next/static, _next/image, and favicon.ico, every CSS chunk, JS bundle, and font request runs through your locale logic. Because those asset paths do not start with a locale segment, a naive middleware redirects /_next/static/chunk.js to /en/_next/static/chunk.js, which 404s — the page renders unstyled and the browser console fills with failed asset loads. The matcher is a positive-lookahead regex against the full pathname, so the exclusion has to be expressed there, not inside the function body.

The second is the redirect loop. Middleware that always prepends a locale prefix will redirect /about/en/about, then run again on /en/about and redirect to /en/en/about, hitting ERR_TOO_MANY_REDIRECTS. The fix is a guard that detects an existing locale prefix and returns early. This is the same idea as the locale-prefix-wins ordering described in locale negotiation strategies: once the URL carries an explicit locale, no further resolution should occur.

Choosing redirect versus rewrite is a deliberate decision, not a style preference. A redirect changes the URL in the address bar to the canonical, locale-prefixed form (/de/about), which is what you want for shareable, indexable URLs and a stable canonical per locale. A rewrite keeps the browser URL bare (/about) while internally serving /de/about — useful for a default locale you want unprefixed, but it produces two URLs that resolve to the same content unless you add an explicit <link rel="canonical">. For most setups, redirect bare paths to the prefixed form.

Minimal reproducible example

This middleware reproduces both bugs. It has no asset exclusion and no prefix guard.

// middleware.ts — BROKEN: loops and mangles asset URLs
import { NextRequest, NextResponse } from 'next/server';

export function middleware(request: NextRequest) {
  const url = request.nextUrl.clone();
  url.pathname = `/en${url.pathname}`; // always prepend — no guard
  return NextResponse.redirect(url);
}

export const config = {
  matcher: '/:path*', // matches EVERYTHING, including /_next/static
};

Requesting /about yields /en/about, then /en/en/about, then ERR_TOO_MANY_REDIRECTS. Asset requests like /_next/static/css/app.css redirect to a non-existent /en/_next/... path and 404.

The corrected middleware excludes Next.js internals in the matcher, resolves the locale once, redirects only bare paths, and stamps both a NEXT_LOCALE cookie and an x-locale request header so server components read the locale without re-parsing the path.

// middleware.ts
import { NextRequest, NextResponse } from 'next/server';

const SUPPORTED = ['en', 'de', 'ja'] as const;
const DEFAULT_LOCALE = 'en';

function resolveLocale(request: NextRequest): string {
  // 1. explicit cookie override wins over browser defaults
  const cookie = request.cookies.get('NEXT_LOCALE')?.value;
  if (cookie && SUPPORTED.includes(cookie as (typeof SUPPORTED)[number])) {
    return cookie;
  }
  // 2. first Accept-Language tag, stripped to its base language
  const header = request.headers
    .get('accept-language')
    ?.split(',')[0]
    ?.split('-')[0]
    ?.toLowerCase();
  if (header && SUPPORTED.includes(header as (typeof SUPPORTED)[number])) {
    return header;
  }
  return DEFAULT_LOCALE; // 3. system fallback
}

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

  // LOOP BREAKER: if the path already has a locale prefix, never redirect.
  const prefixed = SUPPORTED.find(
    (l) => pathname === `/${l}` || pathname.startsWith(`/${l}/`),
  );
  if (prefixed) {
    // Pass the resolved locale to RSC via a request header (no re-parsing).
    const headers = new Headers(request.headers);
    headers.set('x-locale', prefixed);
    return NextResponse.next({ request: { headers } });
  }

  // Bare path → redirect ONCE to the canonical prefixed URL.
  const locale = resolveLocale(request);
  const url = request.nextUrl.clone();
  url.pathname = `/${locale}${pathname === '/' ? '' : pathname}`;
  const response = NextResponse.redirect(url);

  // Persist the choice so the next bare request skips header parsing.
  response.cookies.set('NEXT_LOCALE', locale, {
    path: '/',
    maxAge: 60 * 60 * 24 * 365,
    sameSite: 'lax', // survives top-level navigations, not third-party
  });
  return response;
}

export const config = {
  // Exclude API, Next internals, and the favicon so assets never redirect.
  matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
};

Reading the header in a server component is then a one-liner:

// app/[lang]/layout.tsx
import { headers } from 'next/headers';
import { notFound } from 'next/navigation';

const SUPPORTED = ['en', 'de', 'ja'] as const;

export function generateStaticParams() {
  return SUPPORTED.map((lang) => ({ lang }));
}

export default async function LangLayout({
  children,
  params,
}: {
  children: React.ReactNode;
  params: Promise<{ lang: string }>;
}) {
  const { lang } = await params;
  if (!SUPPORTED.includes(lang as (typeof SUPPORTED)[number])) notFound();

  // x-locale is set by middleware; falls back to the validated route param.
  const locale = (await headers()).get('x-locale') ?? lang;
  return (
    <html lang={locale}>
      <body>{children}</body>
    </html>
  );
}

Two non-obvious details matter here. NextResponse.next({ request: { headers } }) mutates the request headers seen by downstream RSCs — it is not a response header, so it never reaches the browser and never pollutes the cache key. And sameSite: 'lax' is correct for a locale cookie set during a top-level redirect; strict would drop the cookie on inbound links from other sites, silently resetting first-time visitors to the default locale.

Verification

Prove the loop is gone and the prefix is canonical with curl. A bare path should return a single 307 to the prefixed URL; the prefixed URL should return 200, not another redirect.

# Bare path → exactly one redirect to the locale-prefixed URL
curl -sI http://localhost:3000/about | grep -iE 'HTTP|location'
# HTTP/1.1 307 Temporary Redirect
# location: /en/about

# Prefixed path → 200, NO further redirect (loop is broken)
curl -sI http://localhost:3000/de/about | grep -i HTTP
# HTTP/1.1 200 OK

# Accept-Language drives resolution when no cookie is present
curl -sI -H 'Accept-Language: de-DE,de;q=0.9' http://localhost:3000/ | grep -i location
# location: /de

Confirm assets are untouched: curl -sI http://localhost:3000/_next/static/chunk.js must return the asset (or its real 404), never a location: header pointing at a locale prefix. In CI, assert the redirect status and a single Location hop so a future matcher change cannot silently reintroduce the loop.

When to escalate

This middleware-only approach is sufficient for SSR, SSG, and ISR on a Node or edge runtime. It stops being enough in three cases. With output: 'export', middleware does not run at all — locale routing must move to a CDN edge function (a Cloudflare Worker or equivalent) that applies the same prefix guard. With subdomain or ccTLD routing (de.example.com rather than /de/), the redirect target is a different host and you must allowlist hostnames to avoid open-redirect risk. And once you adopt a library such as next-intl, let its bundled middleware own matcher and prefixing rather than stacking two redirect layers — double prefixing reintroduces the exact loop this page fixes. For the broader routing model these cases live in, return to Next.js i18n Routing Setup.

FAQ

Why does my Next.js middleware cause ERR_TOO_MANY_REDIRECTS?

Because it prepends a locale prefix unconditionally and then runs again on the already-prefixed URL. Add a guard that checks whether pathname already starts with a supported locale segment and returns NextResponse.next() early for those requests. Only bare paths should be redirected.

Should I use redirect or rewrite for the locale prefix?

Use redirect when you want the canonical, shareable URL to show the locale (/de/about) in the address bar — best for indexable, per-locale URLs. Use rewrite only when you want a default locale to stay unprefixed (/about serving /en/about internally), and then add an explicit canonical link to avoid two URLs resolving to the same content.

How do server components read the locale the middleware resolved?

Set it as a request header in middleware with NextResponse.next({ request: { headers } }) after adding x-locale, then read it in a server component via headers().get('x-locale'). Because it is a request header it never reaches the browser and never affects the cache key, unlike a response header.

Part of Next.js i18n Routing Setup.