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.

It builds on the broader Next.js i18n Routing Setup guide, and the locale it resolves should feed the same fallback chain your other framework layers use.

Redirect loop versus a guarded single redirect Top row: without a guard, middleware re-prepends the locale on every redirect, so /about becomes /en/about, then /en/en/about, until the browser aborts with ERR_TOO_MANY_REDIRECTS. Bottom row: the loop-breaker guard passes any path that already carries a locale prefix straight through, so /about redirects once to /en/about, which then returns 200 with the x-locale header and NEXT_LOCALE cookie set. Without a prefix guard — the redirect loops /about /en/about /en/en/about ERR_TOO_MANY_REDIRECTS browser aborts the request +/en +/en loops middleware re-runs on every redirect, so the /en prefix keeps stacking until the browser gives up With the loop-breaker guard — exactly one hop /about /en/about 200 OK — passes through sets x-locale header + NEXT_LOCALE cookie no locale prefix → 307 redirect once prefix present → next() + header the guard returns NextResponse.next() early for prefixed paths, so the redirect fires exactly once
The same request, two middlewares: without a prefix guard the locale stacks into ERR_TOO_MANY_REDIRECTS; with it, one 307 hop reaches 200.

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.

Scoping the middleware matcher The matcher negative-lookahead excludes API routes, the Next.js static and image pipelines, the favicon, and any path containing a dot, so build assets and file requests are never rewritten into a locale prefix and cannot start a redirect loop. The matcher that stops the loop export const config = { matcher: [ '/((?!api|_next/static|_next/image |favicon.ico|.*\\..*).*)' ] } api excluded Routes answer JSON, not pages _next excluded Build assets must not redirect Dotted paths excluded Any file with an extension
Three exclusions, three classes of asset that must never be redirected.
// 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.

Middleware guard order for locale prefixes The middleware first lets excluded paths through untouched, then checks whether the first path segment is already a supported locale. If it is, it returns next without redirecting, which is what breaks the loop. Only an unprefixed path is negotiated, once, and answered with a temporary redirect that also persists the choice in a cookie. The guard clause, in the order it must run 1 Is the path excluded by the matcher? asset, API or dotted path → pass through 2 Does the first segment name a locale? pathname.split('/')[1] in SUPPORTED 3 If yes, stop NextResponse.next() — no redirect 4 If no, negotiate once cookie, then Accept-Language, then default 5 Redirect and persist 307 + NEXT_LOCALE cookie
The third step is the whole fix: an already-prefixed path must terminate, not re-enter negotiation.
# 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.