Dev Pattern Labs logo
Dev Pattern Labs Blog
Next.js 10 min readOct 11, 2026

Next.js Middleware: Auth Guards, Redirects & Edge Logic

Master Next.js Middleware for authentication guards, redirects, rewrites, and edge logic. Learn matcher config, cookie-based auth, geolocation redirects, and A/B testing patterns.

Dev Pattern Labs Author

Dev Pattern Labs

Peer-reviewed technical tutorial · Production tested

Server infrastructure representing middleware and edge computing
Executive Summary & Key Insight

Master Next.js Middleware for authentication guards, redirects, rewrites, and edge logic. Learn matcher config, cookie-based auth, geolocation redirects, and A/B testing patterns. This practical guide covers step-by-step setup, real-world code snippets, security hardening, and common production edge cases.

Advertisement

Next.js Middleware is one of the most powerful — and frequently underutilized — features in the modern Next.js ecosystem. Introduced in Next.js 12 and significantly matured through versions 13 and 14, Middleware lets you intercept incoming HTTP requests before they reach your pages, API routes, or static assets. Because it runs at the Edge Runtime (not Node.js), it executes geographically close to your users, delivering extremely low-latency decisions without a cold-start penalty.

Unlike traditional server-side logic that runs after a page component resolves, Middleware is a gatekeeper. It decides what the request becomes before any rendering occurs. This makes it ideal for authentication checks, locale detection, feature flags, A/B test routing, and security rules. If a user hits /dashboard without a valid session token, Middleware can redirect them to /login in a single edge hop — no server-side render wasted, no flash of protected content.

In this guide you will build a solid mental model of how Middleware works, learn the idiomatic patterns that ship in production Next.js apps, and walk through real TypeScript code for every major capability: auth guards, URL rewrites, cookie inspection, geolocation routing, and A/B testing. All examples target the App Router architecture and are compatible with Next.js 14+.


Prerequisites

Before diving in, make sure you are comfortable with:

  • Next.js 13+ fundamentals (App Router, Server Components)
  • TypeScript basics
  • HTTP concepts: cookies, headers, status codes (301, 302, 307, 308)
  • A working Next.js project (npx create-next-app@latest --typescript)

You will also need the next package at version 14.0.0 or higher to use all the features covered here.


Section 1: The middleware.ts File Structure

Middleware lives in a single file at the root of your project (not inside /app or /pages):

my-app/
├── app/
├── public/
├── middleware.ts   ← here
└── next.config.ts

The file must export a default function named middleware and, optionally, a config object that controls which routes it runs on.

// middleware.ts
import { NextRequest, NextResponse } from "next/server";

export function middleware(request: NextRequest): NextResponse {
  // All middleware logic lives here
  return NextResponse.next();
}

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

NextRequest extends the standard Web API Request with Next.js-specific helpers such as .cookies, .geo, and .nextUrl. NextResponse mirrors the Web API Response and adds convenience methods like .redirect(), .rewrite(), and .json().

Returning NextResponse.next() passes the request through unchanged. Returning a redirect or rewrite short-circuits the pipeline — the matched page never renders.


Section 2: Matcher Config – Targeting Specific Routes

The config.matcher array tells Next.js exactly which URL paths should trigger your middleware. Without it, the middleware runs on every request, including _next/static assets, favicon, and images — a performance footgun.

// middleware.ts
export const config = {
  matcher: [
    /*
     * Match all request paths EXCEPT:
     * - _next/static (static files)
     * - _next/image  (image optimization)
     * - favicon.ico
     * - Public assets (png, jpg, svg, etc.)
     */
    "/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
  ],
};

Matcher values support:

  • Exact paths: "/about"
  • Wildcard segments: "/dashboard/:path*"
  • Regex via negative lookahead: as shown above

You can also use an array of multiple patterns:

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

Keeping matcher tight is critical. Every extra route the middleware processes is CPU time on your edge function budget.


Section 3: NextResponse.redirect for Auth Guards

The most common use of Middleware is protecting authenticated routes. Here is a production-quality auth guard:

// middleware.ts
import { NextRequest, NextResponse } from "next/server";

const PROTECTED_ROUTES = ["/dashboard", "/settings", "/profile"];

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

  const isProtected = PROTECTED_ROUTES.some((route) =>
    pathname.startsWith(route)
  );

  if (!isProtected) {
    return NextResponse.next();
  }

  const sessionToken = request.cookies.get("session_token")?.value;

  if (!sessionToken) {
    const loginUrl = new URL("/login", request.url);
    // Preserve the original destination for post-login redirect
    loginUrl.searchParams.set("callbackUrl", pathname);
    return NextResponse.redirect(loginUrl, { status: 307 });
  }

  return NextResponse.next();
}

export const config = {
  matcher: ["/((?!_next/static|_next/image|favicon.ico|login|register).*)"],
};

Use 307 (Temporary Redirect) to preserve the original HTTP method. Use 308 for permanent method-preserving redirects, or 302 when you don't care about method preservation. Avoid 301 for auth redirects — browsers cache permanent redirects aggressively, which causes hard-to-debug logout loops.


Section 4: NextResponse.rewrite for URL Masking

Rewrites let you serve content from a different path while keeping the browser URL unchanged. This is invaluable for multi-tenant apps, URL masking, and gradual feature rollouts.

// middleware.ts – multi-tenant rewrite example
import { NextRequest, NextResponse } from "next/server";

export function middleware(request: NextRequest) {
  const hostname = request.headers.get("host") ?? "";
  const subdomain = hostname.split(".")[0];

  // Map subdomain to a tenant-specific path without changing the URL
  if (subdomain !== "www" && subdomain !== "localhost") {
    const url = request.nextUrl.clone();
    url.pathname = `/tenants/${subdomain}${url.pathname}`;
    return NextResponse.rewrite(url);
  }

  return NextResponse.next();
}

With this pattern, a user visiting acme.yourapp.com/dashboard is transparently served content from /tenants/acme/dashboard. The address bar never changes, and no redirect round-trip is needed.


Section 5: Reading Cookies for Authentication

The Edge Runtime gives you a first-class cookie API via request.cookies. Beyond simple existence checks, you can validate JWT tokens, inspect role claims, and set response cookies — all within the middleware function.

// middleware.ts – JWT role-based access control
import { NextRequest, NextResponse } from "next/server";
import { jwtVerify } from "jose";

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

async function getUserFromToken(token: string) {
  try {
    const { payload } = await jwtVerify(token, JWT_SECRET);
    return payload as { sub: string; role: "user" | "admin" };
  } catch {
    return null;
  }
}

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

  if (!token) {
    return NextResponse.redirect(new URL("/login", request.url));
  }

  const user = await getUserFromToken(token);

  if (!user) {
    // Token is invalid or expired — clear it and redirect
    const response = NextResponse.redirect(new URL("/login", request.url));
    response.cookies.delete("auth_token");
    return response;
  }

  // Admin-only routes
  if (request.nextUrl.pathname.startsWith("/admin") && user.role !== "admin") {
    return NextResponse.redirect(new URL("/403", request.url));
  }

  // Forward user identity to the page via a header
  const requestHeaders = new Headers(request.headers);
  requestHeaders.set("x-user-id", user.sub);
  requestHeaders.set("x-user-role", user.role);

  return NextResponse.next({ request: { headers: requestHeaders } });
}

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

The jose library is Edge-compatible and handles JWT verification without pulling in Node.js crypto modules. Installing it is straightforward: npm install jose.


Section 6: Edge Runtime Capabilities and Limitations

Understanding the Edge Runtime's constraints prevents frustrating runtime errors in production.

CapabilityAvailable in Edge Runtime
Web Fetch API (fetch)✅ Yes
Web Crypto API (crypto.subtle)✅ Yes
TextEncoder / TextDecoder✅ Yes
URL / URLSearchParams✅ Yes
Environment variables (process.env)✅ Yes (build-time safe)
fs / path (Node.js core)❌ No
Native Node.js crypto module❌ No
Database clients (Prisma, pg)❌ No (use Route Handlers)
Long-running processes❌ No (CPU time limited)
Dynamic require() / CommonJS❌ No (ESM only)

The golden rule: Middleware must be stateless and fast. Any I/O that takes more than a few milliseconds — like a database query — belongs in a Route Handler or Server Component, not Middleware. For auth, validate a JWT (CPU-only) in Middleware and fetch the full user profile only once you're inside the page.


Section 7: Geolocation-Based Redirects

Vercel and other edge platforms inject geolocation data into request.geo. You can use this for locale detection, regional compliance (GDPR, CCPA), or simply routing EU users to a closer data region.

// middleware.ts – geolocation redirect
import { NextRequest, NextResponse } from "next/server";

const REGION_ROUTES: Record<string, string> = {
  EU: "/eu",
  US: "/us",
  GB: "/gb",
};

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

  // Skip if the user is already on a regional path
  const alreadyRouted = Object.values(REGION_ROUTES).some((prefix) =>
    pathname.startsWith(prefix)
  );
  if (alreadyRouted) return NextResponse.next();

  const country = request.geo?.country ?? "US";

  // EU countries redirect to /eu
  const EU_COUNTRIES = ["DE", "FR", "IT", "ES", "NL", "PL", "SE", "BE"];
  if (EU_COUNTRIES.includes(country)) {
    return NextResponse.redirect(new URL(`/eu${pathname}`, request.url));
  }

  if (country === "GB") {
    return NextResponse.redirect(new URL(`/gb${pathname}`, request.url));
  }

  // Default to US
  return NextResponse.redirect(new URL(`/us${pathname}`, request.url));
}

export const config = {
  matcher: ["/", "/products/:path*", "/pricing"],
};

When testing locally, request.geo will be undefined. Guard against this by providing sensible defaults (?? "US") and test geo-routing on a staging environment deployed to Vercel or Cloudflare Pages.


Section 8: A/B Testing with Middleware

Middleware is the ideal place to implement A/B testing because you can bucket users at the edge — before any rendering — and serve the correct variant without a layout shift or flicker.

// middleware.ts – cookie-stable A/B test
import { NextRequest, NextResponse } from "next/server";

const EXPERIMENT_COOKIE = "ab_pricing_v2";
const VARIANT_A_PATH = "/pricing";
const VARIANT_B_PATH = "/pricing-v2";
const ROLLOUT_PERCENTAGE = 50; // 50% see variant B

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

  if (!pathname.startsWith("/pricing")) return NextResponse.next();
  // Don't re-bucket users already on a variant path
  if (pathname === VARIANT_B_PATH) return NextResponse.next();

  // Check for an existing bucket assignment
  const existingVariant = request.cookies.get(EXPERIMENT_COOKIE)?.value;

  let variant: "A" | "B";
  if (existingVariant === "A" || existingVariant === "B") {
    variant = existingVariant;
  } else {
    // Randomly assign on first visit
    variant = Math.random() * 100 < ROLLOUT_PERCENTAGE ? "B" : "A";
  }

  const targetPath = variant === "B" ? VARIANT_B_PATH : VARIANT_A_PATH;
  const url = request.nextUrl.clone();
  url.pathname = targetPath;

  const response = NextResponse.rewrite(url);

  // Persist the bucket for the session
  response.cookies.set(EXPERIMENT_COOKIE, variant, {
    maxAge: 60 * 60 * 24 * 30, // 30 days
    httpOnly: true,
    sameSite: "lax",
  });

  return response;
}

export const config = {
  matcher: ["/pricing", "/pricing-v2"],
};

A rewrite is used here (not a redirect) so the URL stays /pricing in the browser regardless of which variant is served. The assignment cookie ensures users see the same variant on every subsequent visit, making your analytics reliable.


Middleware Use Cases at a Glance

Use CaseResponse MethodTypical Cookie/Header Used
Auth guard (redirect to login)NextResponse.redirectsession_token, auth_token
Role-based access controlNextResponse.redirectJWT with role claim
Multi-tenant routingNextResponse.rewriteHost header / subdomain
Internationalization (i18n)NextResponse.redirectAccept-Language, NEXT_LOCALE
Geolocation routingNextResponse.redirectrequest.geo.country
A/B testingNextResponse.rewriteCustom experiment cookie
Security headers injectionNextResponse.next() + headers—
Maintenance modeNextResponse.rewriteFeature flag env var
Rate limiting (edge)NextResponse.json (429)IP via x-forwarded-for

Conclusion

Next.js Middleware sits at the perfect intersection of performance and flexibility. By running at the Edge — milliseconds from your users — it gives you a synchronous, low-latency control plane over every HTTP request your application receives.

Key takeaways:

  1. Place middleware.ts at the project root, not inside /app or /pages.
  2. Always configure a tight matcher to avoid running middleware on static assets and unrelated routes.
  3. Use NextResponse.redirect for hard navigation changes (auth guards, geo-routing) and NextResponse.rewrite when you want to serve different content without changing the URL.
  4. Validate JWTs with jose instead of Node.js crypto — the Edge Runtime is ESM-only and has no access to Node.js built-ins.
  5. Keep Middleware fast and stateless — no database queries, no heavy computation. Offload those to Server Components or Route Handlers.
  6. Persist A/B test assignments in cookies with a long maxAge so users receive consistent experiences across sessions.
  7. Guard against missing request.geo data when developing locally by providing safe defaults.

With these patterns in place, your Next.js application gains a robust, edge-native request pipeline that handles auth, routing, experimentation, and personalization — all before a single component renders.

Tags:#Next.js#Middleware#Authentication#Edge Runtime
Dev Pattern Labs Engineering Team

Written by Dev Pattern LabsCore Engineering

Dev Pattern Labs is an engineering lab and digital consultancy specializing in custom Next.js web applications, WooCommerce REST API integrations, and workflow automation for growing businesses.

Advertisement

Recommended Companion Articles

API documentation and code on a monitor
Next.js

Building REST APIs with Next.js Route Handlers

Learn to build production-ready REST APIs with Next.js Route Handlers. Covers GET, POST, PUT, DELETE methods, NextRequest/NextResponse, query params, CORS, streaming responses, and dynamic segments.

11 min read
Developer working on Next.js caching configuration
Next.js

Next.js 15 Caching Strategies Explained

Understand all Next.js 15 caching layers: fetch cache options, Route Segment Config, full-route cache, data cache, router cache, and on-demand revalidation with revalidatePath and revalidateTag.

13 min read