Next.js Middleware Patterns Explained: Routing, Auth, and Rewrites
AI generated
</>
{ }
React · Next.js · Middleware · Edge Runtime
Next.js Middleware Patterns Explained
Routing, auth, and rewrites before rendering

Next.js middleware runs before a route is even rendered, deciding on redirects, rewrites, and headers before Server Components or Client Components become active. Once you understand matcher rules, auth checks, and the limits of the edge runtime, you can build routing logic that stays fast and still behaves robustly.

18 min read Matcher · Auth · Rewrites · Geolocation Next.js 14/15 · App Router

1. What Next.js middleware is and when it runs

Next.js middleware is a single file, middleware.ts at the project root, executed in the edge runtime before every request, before a route is even matched, a cache is read, or a Server Component is rendered. Unlike a route handler function that produces its own response, Next.js middleware sits as a layer in front of everything: it can let a request pass through, redirect it, rewrite it, or attach extra headers. This position in the request lifecycle makes it the right place for decisions that apply to many routes at once.

The key difference from classic server-side routing is that Next.js middleware is not configured per page, but applies globally through a matcher rule. An auth check, a locale redirect, or a feature flag can be implemented in a single place instead of being repeated in every individual page.tsx. This centralization is the real value of Next.js middleware: less duplication, a single place for cross-cutting concerns.

The order in the Next.js model matters: middleware runs before the routing layer, but it does not have access to the full Node.js ecosystem, because it executes in the edge runtime by default. That means Next.js middleware is excellent for fast, stateless decisions based on request headers, cookies, or the URL, but not for heavy database queries or Node-specific libraries. That limitation is covered in detail in section six.

2. Middleware matcher config: filtering paths precisely

Without a matcher configuration, Next.js middleware runs for virtually every request by default, including static assets, which creates unnecessary overhead. The exported config block with a matcher array precisely limits which paths the middleware even runs for. A precise matcher not only saves compute time, it also prevents the middleware from accidentally touching internal Next.js routes like _next/static or image optimization endpoints and triggering unwanted side effects there.

Matcher patterns support both simple path prefixes and full regular expressions via path-to-regexp syntax, which also allows negative lookaheads. That lets you match everything except certain paths, for example everything except API routes and static files. In more complex Next.js middleware setups with several independent responsibilities, say auth for the admin area and geolocation for checkout, it pays off to branch by path prefix inside the one middleware function instead of trying to simulate multiple middleware files, which Next.js technically does not support.


// middleware.ts — matcher config excludes static assets and API internals
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

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

  // Branch by path prefix inside a single middleware function
  if (pathname.startsWith('/admin')) {
    return handleAdminAuth(request);
  }
  if (pathname.startsWith('/checkout')) {
    return handleCheckoutGeo(request);
  }

  return NextResponse.next();
}

function handleAdminAuth(request: NextRequest) {
  const token = request.cookies.get('session')?.value;
  if (!token) {
    return NextResponse.redirect(new URL('/login', request.url));
  }
  return NextResponse.next();
}

function handleCheckoutGeo(request: NextRequest) {
  return NextResponse.next();
}

// Negative lookahead: match everything except static files and API routes
export const config = {
  matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
};

A common mistake in the matcher config is an overly broad pattern like matcher: ['/:path*'], which routes every request, including image requests, through Next.js middleware. That not only costs latency, it can also lead to infinite loops in cookie manipulation or redirect logic if a redirect target is matched again by the same rule. A clean matcher is therefore not a cosmetic detail, it is a basic requirement for stable Next.js middleware.

3. Authentication and redirects in middleware

By far the most common use case for Next.js middleware is protecting entire route trees from unauthenticated access. Instead of repeating a session check in every Server Component, the middleware reads the session cookie, validates a JWT or calls a lightweight auth endpoint, and immediately redirects with NextResponse.redirect() if authorization is missing, before any rendering work is invested. That saves server time and prevents protected content from briefly appearing in the initial HTML.

An important aspect of Next.js middleware and auth is that the middleware itself should not open a database connection. Instead, an already issued, signed token is checked, for example with an edge-compatible JWT library like jose. The actual session validation against the database stays reserved for the Server Component or route handler, and the middleware only makes the coarse pre-decision: pass through or redirect to the login page. This division of labor keeps Next.js middleware fast and avoids it becoming a bottleneck.

For redirects with a preserved return path, one Next.js middleware pattern is particularly common: the originally requested URL is appended as a query parameter to the login page, so the user automatically returns to the originally intended page after a successful login. Without this pattern, users always land on the home page after login, which noticeably worsens usability in practice.


// middleware.ts — auth guard with return-path preservation
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { jwtVerify } from 'jose';

const secret = new TextEncoder().encode(process.env.JWT_SECRET);

export async function middleware(request: NextRequest) {
  const token = request.cookies.get('session')?.value;

  if (!token) {
    return redirectToLogin(request);
  }

  try {
    await jwtVerify(token, secret);
    return NextResponse.next();
  } catch {
    // Token invalid or expired — treat as unauthenticated
    return redirectToLogin(request);
  }
}

function redirectToLogin(request: NextRequest) {
  const loginUrl = new URL('/login', request.url);
  loginUrl.searchParams.set('from', request.nextUrl.pathname);
  return NextResponse.redirect(loginUrl);
}

export const config = {
  matcher: ['/dashboard/:path*', '/settings/:path*'],
};

4. Rewrites and A/B testing with middleware

Besides redirects, which tell the browser a new URL, Next.js middleware can also perform rewrites: the user still sees the original URL in the address bar, but internally a different page is served. That is the technical foundation for server-side A/B testing without client-side flicker, because the decision is made before any HTML is rendered, instead of swapping the variant afterward via JavaScript.

A typical pattern reads a cookie with the assigned test group, and assigns that cookie randomly on first visit, so a user consistently sees the same variant across multiple sessions. Next.js middleware is the only place in the App Router where this decision truly happens before rendering, because a rewrite in a Server Component would come too late, rendering would have already started. For marketing teams testing landing page variants, this pattern is the difference between a clean test and a flicker problem in the Core Web Vitals report.


// middleware.ts — server-side A/B test via rewrite, no client flicker
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

export function middleware(request: NextRequest) {
  if (request.nextUrl.pathname !== '/landingpage') {
    return NextResponse.next();
  }

  const existingVariant = request.cookies.get('ab-variant')?.value;
  const variant = existingVariant ?? (Math.random() < 0.5 ? 'a' : 'b');

  const response = variant === 'b'
    ? NextResponse.rewrite(new URL('/landingpage/variant-b', request.url))
    : NextResponse.next();

  if (!existingVariant) {
    // Persist assignment for 30 days so returning users stay in the same variant
    response.cookies.set('ab-variant', variant, { maxAge: 60 * 60 * 24 * 30 });
  }

  return response;
}

export const config = {
  matcher: ['/landingpage'],
};

5. Geolocation and header-based personalization

When Next.js runs on Vercel or a similar edge platform, the runtime exposes geographic metadata directly on the request object for Next.js middleware, without needing to call an external geolocation service. Country, region, and city can be used for language redirects, currency display, or regional offers, and with minimal latency, because the information is already available at the edge instead of requiring a roundtrip to a third party first.

A common Next.js middleware pattern redirects visitors without an explicit language choice in the path based on geolocation and the Accept-Language header to the matching language version, for example from / to /de or /en. Importantly, this redirect should only apply once and then set a preference cookie, so a user who deliberately picks a different language is not redirected on every page visit. Without this cookie memory, personalization quickly turns into a user trap.


// middleware.ts — locale redirect based on geolocation and Accept-Language
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

const SUPPORTED_LOCALES = ['de', 'en'] as const;

export function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl;
  const hasLocale = SUPPORTED_LOCALES.some((l) => pathname.startsWith(`/${l}`));
  if (hasLocale) return NextResponse.next();

  const preferred = request.cookies.get('preferred-locale')?.value;
  if (preferred) {
    return NextResponse.next();
  }

  const country = request.geo?.country ?? 'US';
  const locale = country === 'DE' || country === 'AT' || country === 'CH' ? 'de' : 'en';

  const url = request.nextUrl.clone();
  url.pathname = `/${locale}${pathname}`;
  const response = NextResponse.redirect(url);
  response.cookies.set('preferred-locale', locale, { maxAge: 60 * 60 * 24 * 365 });
  return response;
}

6. Middleware and the edge runtime: understanding limits

Next.js middleware runs in the edge runtime by default, a restricted JavaScript environment based on web standard APIs, not on full Node.js. Concretely that means no filesystem access, no native TCP socket, no Node-specific modules like fs or the classic Node-style crypto API. Instead, web APIs like fetch, Request, Response, and the Web Crypto API are available, the same ones that exist in the browser.

This restriction is not accidental, it is the precondition for the low cold-start time the edge runtime is known for. Anyone who tries to use a classic Node ORM like Prisma with a direct TCP database connection inside Next.js middleware gets a build or runtime error, because the underlying Node APIs are missing. The right approach is either an edge-compatible database client over HTTP, for example with Neon or PlanetScale, or moving the actual database logic into a route handler that runs in the Node runtime.

Newer Next.js versions allow experimentally switching the middleware runtime to Node as well, which allows more compatibility but gives up the cold-start advantages of the edge runtime. This decision, edge versus Node for Next.js middleware, ties directly into the topic of section three of this article and should be made based on actual latency requirements, not blanket assumptions.

7. Performance overhead and when to avoid middleware

Every Next.js middleware execution adds extra latency to the request path, even if the middleware only reads a single header. On globally distributed edge deployments this overhead is usually in the low single-digit millisecond range, but it can add up if the middleware synchronously waits on an external service, for example a remote feature flag service without local caching. An unnecessary fetch call inside middleware is the most common performance killer in this context.

Not every cross-cutting concern belongs in Next.js middleware. Security headers that are identical for every route can often be set more efficiently via next.config.js, without any JavaScript code having to run per request at all. Middleware should stay reserved for decisions that are genuinely request-dependent: auth status, A/B assignment, geolocation. Everything else is statically configurable and cheaper than even the leanest middleware function.

8. Mistakes and anti-patterns with Next.js middleware

The biggest classic anti-pattern is middleware that itself produces a URL matched again by its own matcher, causing a redirect loop. This often happens with locale redirects, when the target URL accidentally carries the same path prefix as the source URL. A second widespread mistake is heavy computation or several sequential fetch calls inside a single middleware instance, which negates the cold-start and latency balance of the edge runtime mentioned earlier.

A third mistake concerns cookies: middleware can set cookies, but changes to request.cookies do not automatically carry over to response.cookies, they must be transferred to the response object explicitly. Anyone who overlooks this wonders why a cookie set in middleware never arrives in the browser. Also common is trying to use environment variables in Next.js middleware that lack the NEXT_PUBLIC_ prefix, even though the edge runtime has its own rules for bundling server variables that differ from classic Node environment variables.

9. Next.js middleware compared

To decide where a piece of logic belongs, a direct comparison of the available Next.js mechanisms helps. The following table places Next.js middleware against the alternatives route handler, Server Component, and next.config.js configuration.

Use case Unsuitable Recommended mechanism Reason
Route-wide auth protection Check in every page.tsx Next.js middleware with matcher Centralized, runs before rendering
Static security headers Set per request in middleware next.config.js headers() No JS overhead per request
Database query before rendering Directly in middleware Route handler or Server Component Node runtime, full DB drivers
A/B test without flicker Client-side redirect Middleware rewrite Decision before first render
Geolocation personalization External geo API call on client request.geo in middleware Already available at the edge

The table makes clear: Next.js middleware is not a replacement for route handlers or Server Components, it is a pre-decision layer with its own deliberately narrow capabilities. Respect that boundary and you get fast, maintainable routing logic. Ignore it, and you push Node-specific code into an environment that was not built for it, risking build errors or poor latency numbers.

Mironsoft

Next.js architecture, middleware, and edge deployments

Middleware that cleanly separates auth, rewrites, and geolocation?

We build Next.js middleware patterns with precisely scoped matcher rules, correctly secured auth redirects, and respect for edge runtime limits, without giving away cold-start advantages.

Middleware audit

Checking matcher rules and redirect chains for loops and overhead

Auth architecture

Edge-compatible token validation with clean separation from the session database

Edge migration

Making sound runtime decisions between edge and Node

10. Summary

Next.js middleware is the layer that decides before every render: pass through, redirect, or rewrite. With a precise matcher configuration it stays limited to exactly the paths where it is actually needed. Auth redirects, A/B testing rewrites, and geolocation-based personalization are the three core use cases that work without client-side flicker thanks to the position of middleware in the request lifecycle.

The biggest constraint, the edge runtime, is also the reason for the low latency of Next.js middleware. Anyone who moves Node-specific libraries or heavy database access into middleware violates this model and gets either build errors or poor performance. The clean division of labor, middleware for fast pre-decisions, route handlers and Server Components for everything else, is the key to robust Next.js middleware patterns in production.

Next.js Middleware Patterns: The Essentials at a Glance

Matcher config

Precise path rules in the config export prevent unnecessary overhead and redirect loops on every middleware execution.

Auth before rendering

Token check instead of database access in middleware, redirect with return path for good login usability.

Rewrites for A/B tests

Server-side variant selection via cookie and rewrite prevents client-side flicker in Core Web Vitals.

Respecting edge limits

No Node APIs, no heavy DB drivers. Use edge-compatible clients or move logic to route handlers.

11. FAQ: Next.js Middleware Patterns

1What exactly is Next.js middleware?
A middleware.ts file that runs before routing and rendering, able to pass through, redirect, or rewrite requests.
2What is the matcher for?
Limits which paths the middleware runs for, preventing unnecessary overhead on static assets.
3Direct database access possible?
Only through edge-compatible HTTP clients. Heavy DB access belongs in route handlers or Server Components.
4Rewrite vs. redirect?
Redirect changes the visible URL, rewrite serves a different page internally without visible change.
5Avoiding redirect loops?
Target URL must not be matched again by your own matcher, use a cookie or prefix check.
6Why doesn't the cookie arrive?
Must be set explicitly on response.cookies, request.cookies alone carries nothing over.
7Always edge runtime?
By default yes, Node runtime experimentally possible, but with longer cold starts.
8Using geolocation?
request.geo provides country, region, and city directly on the request object on supported edge platforms.
9Security headers in middleware?
Static headers are better placed in next.config.js, reserve middleware for request-dependent decisions.
10How to test locally?
Use next dev, simulate headers and cookies via DevTools or curl, unit-test the function with mocked requests.