Skip to main content
BlogWeb Dev

Next.js Middleware: Real Use Cases Beyond the Docs

Practical Next.js middleware patterns for authentication guards, i18n locale detection, A/B testing, and security headers — with code and gotchas.

Matthews Wong

Software Engineer

June 20259 min read
Web Dev

Next.js middleware runs at the edge before any route handler or Server Component. It's one of the most powerful and misunderstood features of the framework. My portfolio site (matthewswong.com) uses middleware for i18n locale detection with next-intl. ERP applications I've built use middleware for authentication redirects and CSP headers. Here's how middleware actually works in production and where it fits — and where it doesn't.

What Middleware Is (and Isn't)

Middleware is a function that runs on the Edge Runtime before a request reaches your route handler, Server Component, or static file. It can read the request, modify headers, rewrite the URL, redirect, or return a response directly. What it is NOT: middleware is not a secure authorization layer. The CVE-2025-29927 vulnerability demonstrated that middleware can be bypassed by sending a crafted x-middleware-subrequest header. Authorization must happen inside your route handlers and Server Components — middleware handles routing decisions.

i18n Locale Detection with next-intl

The most common middleware use case in Next.js is internationalization. next-intl's middleware detects the user's preferred locale from: (1) the URL prefix (/en/, /id/), (2) a locale cookie set by a previous visit, (3) the Accept-Language request header. It then redirects or rewrites the request to the appropriate locale path. My portfolio site uses this pattern — visiting matthewswong.com with an Indonesian browser locale redirects to /id automatically. The middleware adds a response cookie to remember the choice for future visits.

Request lifecycle with Next.js middleware:

Incoming Request
      │
      ▼
middleware.ts (Edge Runtime)
      │
      ├── 1. i18n: detect locale from URL/cookie/Accept-Language
      │          → rewrite /products → /en/products (or /id/products)
      │
      ├── 2. Auth: check session cookie / JWT
      │          → if missing: redirect to /[locale]/login
      │          → if present: continue (actual authz in route handler)
      │
      ├── 3. Security headers: add CSP, HSTS, X-Frame-Options
      │
      └── 4. Pass to route handler / Server Component
                │
                ▼
         Route Handler (actual authorization lives here)
         Server Component (data fetching, rendering)

Middleware matcher — CRITICAL for performance:
export const config = {
  matcher: [
    '/((?!_next/static|_next/image|images|favicon.ico|api/health).*)',
  ],
}
// Without this: middleware runs on EVERY request including static files

From integrating next-intl middleware with Clerk authentication on matthewswong.com's admin routes: compose middleware functions using createMiddleware from next-intl and wrap it with Clerk's authMiddleware. Run i18n detection first, then auth check, so redirects go to the correct locale's login page (e.g., /id/login rather than /en/login). The order matters — auth middleware needs to know the locale to construct the correct redirect URL.

Authentication Guards in Middleware

Middleware is appropriate for authentication redirects — checking if a session cookie exists and redirecting to /login if not. What it's NOT appropriate for: checking permissions, roles, or resource-level access. The reason: middleware runs on the Edge Runtime, which has limited access to your database or complex business logic. Check the session token validity in middleware (a JWT verification is fast and CPU-bound), redirect unauthenticated users, but leave the actual authorization (can this user access this resource?) to the Server Component or Route Handler.

// middleware.ts — production example (i18n + auth + CSP headers)
import { NextRequest, NextResponse } from 'next/server'
import createMiddleware from 'next-intl/middleware'
import { routing } from './i18n/routing'

const intlMiddleware = createMiddleware(routing)

const PROTECTED_PATHS = ['/dashboard', '/admin', '/settings']

export function middleware(req: NextRequest) {
  const pathname = req.nextUrl.pathname

  // Run i18n middleware first (handles locale detection + redirect)
  const intlResponse = intlMiddleware(req)

  // Add security headers to every response
  const response = intlResponse ?? NextResponse.next()
  response.headers.set('X-Frame-Options', 'DENY')
  response.headers.set('X-Content-Type-Options', 'nosniff')
  response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin')
  response.headers.set(
    'Content-Security-Policy',
    "default-src 'self'; script-src 'self' 'nonce-{NONCE}'; style-src 'self' 'unsafe-inline'"
  )

  // Auth check for protected routes (JWT from cookie)
  const isProtected = PROTECTED_PATHS.some(p => pathname.includes(p))
  if (isProtected) {
    const token = req.cookies.get('auth-token')?.value
    if (!token) {
      // Extract locale from pathname for correct redirect
      const locale = pathname.startsWith('/id') ? 'id' : 'en'
      return NextResponse.redirect(new URL(`/${locale}/login`, req.url))
    }
    // NOTE: Don't verify JWT here for complex auth — do that in the route handler
    // Middleware just checks for presence; route handler does actual verification
  }

  return response
}

export const config = {
  matcher: ['/((?!_next/static|_next/image|images|favicon.ico|api/health).*)'],
}

// A/B testing at the edge
export function middleware(req: NextRequest) {
  const bucket = req.cookies.get('ab-bucket')?.value ?? (Math.random() > 0.5 ? 'a' : 'b')
  const url = req.nextUrl.clone()
  url.pathname = `/landing-${bucket}`

  const response = NextResponse.rewrite(url)
  response.cookies.set('ab-bucket', bucket, { maxAge: 60 * 60 * 24 * 30 })
  return response
}

Security Headers via Middleware

Middleware is an excellent place to add security response headers globally — Content-Security-Policy, X-Frame-Options, X-Content-Type-Options, Referrer-Policy. Setting these in middleware ensures every response (pages, API routes, static files served via middleware) gets the headers. The alternative — setting them in next.config.js headers() — only applies to page routes. For CSP headers with nonces (required for inline scripts), middleware is the only place to generate the nonce and attach it to both the header and the rendering context.

By default, Next.js middleware runs on EVERY request including static files, images, and API routes. Use the matcher config to limit which paths trigger your middleware. A poorly configured middleware that runs on every /_next/image request adds latency to every image load. At minimum, exclude /_next/static, /_next/image, /images/, and /favicon.ico from your matcher. Profile your middleware execution time in Vercel's edge function logs — even 5ms on every image request adds up.

A/B Testing at the Edge

Middleware enables true server-side A/B testing without client-side flicker. Assign a user to a variant (using a cookie or the user ID from a JWT), then rewrite the URL to the appropriate page: /landing-a or /landing-b, both accessible at /. The user sees /campaign in the URL but gets the variant page. Because this happens at the edge before any rendering, there's no flash of the control variant before the test variant loads — a significant advantage over client-side A/B testing.

What I Use and Why

On every Next.js project, my middleware.ts handles: (1) i18n locale detection and redirection via next-intl. (2) Session check and auth redirect for protected routes. (3) Security headers (CSP, HSTS, X-Frame-Options). That's it. I don't put business logic, database calls, or complex authorization in middleware. The Edge Runtime limitations (no Node.js APIs, limited npm package support) make complex middleware fragile. Keep middleware lean — it runs on every request.

Middleware File Structure and Composition

Next.js supports only one middleware.ts file at the project root. For complex middleware with multiple concerns (auth + i18n + CSP), I chain them using a compose pattern: export the main function that runs each concern in sequence and merges the response headers. next-intl provides a createMiddleware factory that handles i18n and returns a middleware function you can compose with. Clerk provides clerkMiddleware() that wraps your middleware logic. Test your middleware composition carefully — incorrect response merging can strip headers or lose redirect information.

Sources & Further Reading

FAQ

Frequently Asked Questions

Short answers to what readers ask most about this topic.

Can Next.js middleware be used as a secure authorization layer?

No — middleware is not a secure authorization layer. The CVE-2025-29927 vulnerability demonstrated that middleware can be bypassed by sending a crafted x-middleware-subrequest header. Middleware should only handle routing decisions like redirects; actual authorization (checking permissions, roles, or resource-level access) must happen inside your route handlers and Server Components.

Why does the order of i18n and auth middleware composition matter?

The i18n middleware must run before the auth middleware so that authentication redirects go to the correct locale's login page — for example /id/login rather than /en/login. The auth middleware needs to know the detected locale to construct the correct redirect URL, which only works if locale detection has already been performed.

What should — and should not — be put in Next.js middleware?

Middleware is well-suited for locale detection and redirection via next-intl, session token checks with JWT verification, and adding global security headers like CSP and X-Frame-Options. It should NOT contain database calls, complex business logic, or role/permission checks, because the Edge Runtime has limited access to Node.js APIs and npm packages, making complex middleware fragile and slow.

Why is the middleware matcher config important for performance?

By default, Next.js middleware runs on every request, including static files and images. A poorly configured middleware that triggers on every /_next/image request adds latency to every image load. At minimum, you should exclude /_next/static, /_next/image, /images/, and /favicon.ico from your matcher to avoid unnecessary overhead.

How does edge-based A/B testing in middleware eliminate client-side flicker?

By assigning a user to a variant via a cookie or JWT and rewriting the URL at the edge before any rendering occurs, middleware delivers the correct variant page immediately — there is no flash of the control variant before the test variant loads. This is a significant advantage over client-side A/B testing, where the page must first render before JavaScript can swap the content.