React
Middleware for locale redirects
A middleware.ts file reads the browser language and sends the root to /it or /en. Matcher, cookie, Vary and the loop that starts without the guard.
Key points
- The middleware decides the URL before the cache and before the page.
- The matcher must exclude _next, API routes and files with an extension.
- Without a check for a locale already in the path, the redirect loops.
- Vary: Accept-Language stops the CDN serving the wrong redirect.
A locale redirect lives in middleware.ts: read Accept-Language or a cookie and rewrite the URL towards /it or /en before the page renders. The middleware does not change the content, it changes the address.
The smallest file
// middleware.ts
import { NextResponse, type NextRequest } from "next/server";
const LOCALES = ["it", "en"] as const;
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
const hasLocale = LOCALES.some(
(l) => pathname === `/${l}` || pathname.startsWith(`/${l}/`),
);
if (hasLocale) return NextResponse.next();
const cookie = request.cookies.get("locale")?.value;
const header = request.headers.get("accept-language") ?? "";
const preferred =
cookie && LOCALES.includes(cookie as "it" | "en")
? cookie
: header.toLowerCase().startsWith("it")
? "it"
: "en";
const url = request.nextUrl.clone();
url.pathname = `/${preferred}${pathname === "/" ? "" : pathname}`;
const response = NextResponse.redirect(url, 307);
response.headers.set("Vary", "Accept-Language, Cookie");
return response;
}
export const config = {
matcher: ["/((?!_next|api|.*\\..*).*)"],
};
Why the matcher matters
The middleware runs on every request it matches. If the pattern includes /_next or static files, every asset goes through the check and a redirect can hit an image. The pattern above excludes _next, API routes and anything with a dot in its name.
The loop, and how to avoid it
The loop starts when the redirect does not check whether the URL already carries a locale: /it lands on /it/it, then on /it/it/it. The hasLocale guard is the single line that prevents the disaster.
| Signal | Priority | Why |
|---|---|---|
locale cookie | 1 | The user's explicit choice beats the browser |
Accept-Language | 2 | First visit, no saved choice |
| Fallback | 3 | Header missing or locale unsupported |
Save the cookie when the reader changes language in the switcher, with path=/ and a long max-age. The redirect then stops recomputing from the header on every visit.
Cache and runtime
- The redirect is an HTTP response: without
Varya CDN may serve it to everyone. - The middleware runs on the edge by default: no database drivers it cannot run.
- An internal rewrite with
NextResponse.rewritekeeps the URL clean and serves/it/...underneath. - The middleware does not run with
output: "export": a static site has no server to execute it.
The switcher needs a dropdown or a navigation component, mounted in a header block.
Hreflang mistakes are in hreflang, common errors; the canonical in canonicalisation; another middleware use case in middleware for authentication. Comparison: Remix vs Next.js.
curl -sI http://localhost:3000/
Known limitation: the middleware cannot see the page content and cannot read the database in a portable way. If the choice depends on the account, decide in the page, not in the middleware.
Frequently asked questions
Where does the middleware file go?
In the project root or inside src, at the same level as app. One middleware.ts file per project.
Can I query the database in the middleware?
Only with drivers that run on the edge runtime. For ordinary queries move the decision into a server component or a route handler.
Why do I get a redirect loop?
Because the middleware does not check whether the URL already carries a locale. Add the guard on the first segment before redirecting.
Is Vary: Accept-Language needed?
Yes. Without that header a shared cache can return the first visitor's language to everyone.
Does the redirect hurt SEO?
No, if it is a 307 or 308 and points at a URL with a reciprocal hreflang. The damage comes from serving the same page on two URLs without a canonical.