← All posts

Next.js i18n middleware: redirect loops and what to test

Where locale detection and i18n redirects happen in Next.js — and where most bugs live. Edge runtime limits, matcher config, and testing without deploying.

Next.js i18n middleware: redirect loops and what to test

Vercel's edge middleware runs before any page renders, on every request, at the CDN edge. For an internationalised Next.js site, middleware is where locale detection, redirects, and path rewriting happen. It's also where a surprisingly large number of bugs live, because middleware runs outside of React and outside of Node.js — it's a restricted environment with its own constraints.

Here's what I've run into building and debugging i18n middleware on Vercel.

The two jobs middleware does for i18n

When a visitor hits klawsfx.com/ with no locale prefix, something has to decide whether to redirect them to /en or /el. That's middleware job one: locale detection.

When a visitor hits /services and there's no such route — only /en/services and /el/services — middleware has to rewrite the path transparently. That's job two: path rewriting.

next-intl's createMiddleware handles both. The key is understanding what it uses for locale detection and in what order:

  1. Existing cookie — if the user has visited before, the locale is stored in a cookie and respected.
  2. Accept-Language header — the browser sends the preferred language; middleware maps it to a supported locale.
  3. Default locale — if neither matches, fall back to the configured default.

Matcher config is the most common source of bugs

Middleware runs on matched paths. The config.matcher export in middleware.ts defines which paths trigger middleware. If the matcher is too broad, middleware runs on API routes, static assets, and _next internals — which is wasteful and can cause issues. If it's too narrow, locale redirects don't fire.

next-intl's recommended matcher excludes internals explicitly:

export const config = {
  matcher: [
    '/((?!_next|_vercel|.*\\..*).*)',
  ],
};

The regex reads: match all paths except those starting with _next, _vercel, or containing a dot (which covers .jpg, .png, .ico, etc.).

If you're also running auth middleware (checking a session cookie before serving admin routes), combine the logic inside a single middleware function — don't try to compose two separate middleware files. Next.js only supports one middleware export.

// middleware.ts
const intlMiddleware = createIntlMiddleware(routing);

export default function middleware(request: NextRequest) {
  const isAdminRoute = request.nextUrl.pathname.startsWith('/admin');

  if (isAdminRoute) {
    const sessionCookie = request.cookies.get('session');
    if (!sessionCookie) {
      return NextResponse.redirect(new URL('/login', request.url));
    }
  }

  return intlMiddleware(request);
}

The edge runtime import constraint

Middleware runs in the Vercel Edge Runtime, not the standard Node.js runtime. The edge runtime is a restricted environment — it supports Web APIs but not the full Node.js API surface. Specifically:

If you try to import any of these in your middleware file, the build will fail with The edge runtime does not support Node.js 'fs' module. The fix is always to move that logic out of middleware and into the API route or page that needs it, or to use a Web-compatible alternative.

For JWT verification in middleware, use the jose library instead of jsonwebtoken:

import { jwtVerify } from 'jose';

const secret = new TextEncoder().encode(process.env.JWT_SECRET);
const { payload } = await jwtVerify(token, secret);

jose is edge-compatible. jsonwebtoken is not.

Redirect loops from misconfigured locale handling

The most disorienting middleware bug is a redirect loop. The browser hits a URL, gets a 307, follows it, gets another 307, and eventually Chrome shows ERR_TOO_MANY_REDIRECTS.

This almost always means middleware is redirecting a URL that it already redirected to. Common causes:

The diagnostic is to console.log the request.nextUrl.pathname and the response destination at the start of middleware, then check Vercel's function logs. You'll see the loop in the log sequence immediately.

Locale cookie domain on Vercel preview deployments

next-intl sets a locale cookie to persist the user's language choice. By default the cookie has no explicit domain, which means it's scoped to the exact hostname — fine for production, but a problem for Vercel preview deployments.

Preview deployments get URLs like klawsfx-git-feature-xyz.vercel.app. If the cookie was set on a previous preview deploy at a different URL, it won't be sent to this one. So you get inconsistent locale behaviour when testing on previews.

This isn't something you need to fix for production — it's expected behaviour. But it's worth knowing when you're debugging locale issues on a preview URL and the behaviour differs from local development.

Testing middleware without deploying

Middleware runs in the edge runtime, which isn't identical to the local Next.js dev server. Most issues appear locally, but some — particularly around response headers, cookies, and runtime imports — only surface on Vercel.

Vercel's CLI (vercel dev) runs a closer simulation of the edge environment than next dev. For middleware-specific debugging, I use vercel dev locally and check the function logs there before pushing.

For redirect and rewrite logic, writing a test that directly calls the middleware function with a mock NextRequest is faster than deploying and checking:

import { describe, it, expect } from 'vitest';
import middleware from '../middleware';
import { NextRequest } from 'next/server';

describe('i18n middleware', () => {
  it('redirects root to default locale', async () => {
    const request = new NextRequest('http://localhost:3000/');
    const response = await middleware(request);
    expect(response.status).toBe(307);
    expect(response.headers.get('location')).toContain('/en');
  });
});

The mock doesn't cover the full edge runtime, but it catches most redirect and rewrite logic errors without a deploy cycle.