Skip to content
Codeloom
Next.js

Next.js Middleware: A Complete Guide

Everything you need to know about Next.js middleware: how it works, matchers, redirects, rewrites, authentication checks, geolocation, rate limiting, and common patterns for production apps.

·10 min read · By Codeloom
Intermediate 15 min read

What you'll learn

  • How Next.js middleware executes at the edge
  • Configuring matchers to control which routes use middleware
  • Redirects, rewrites, and header manipulation
  • Authentication and authorization patterns
  • Rate limiting and geolocation-based routing

Prerequisites

  • Next.js App Router fundamentals
  • Basic understanding of HTTP headers and cookies
  • Familiarity with async/await

Next.js middleware runs before a request is completed. It sits between the incoming request and your route handlers, giving you a chance to rewrite, redirect, modify headers, or block requests entirely. Middleware runs at the edge, which means low latency, but also a restricted runtime without full Node.js APIs.

How middleware works

Middleware is defined in a single middleware.ts (or .js) file at the root of your project (next to app/ or pages/). Every matched request passes through this function before reaching the route handler.

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

export function middleware(request: NextRequest) {
  console.log("Request path:", request.nextUrl.pathname);
  return NextResponse.next();
}

NextResponse.next() tells Next.js to continue to the route handler. You can also return NextResponse.redirect(), NextResponse.rewrite(), or a plain Response object to short-circuit the request.

The matcher config

By default, middleware runs on every request, including static assets and images. The config.matcher export lets you scope middleware to specific paths.

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

export function middleware(request: NextRequest) {
  // This only runs for matched paths
  return NextResponse.next();
}

export const config = {
  matcher: [
    // Match all paths except static files and images
    "/((?!_next/static|_next/image|favicon.ico).*)",
  ],
};

You can specify multiple matchers as an array. Matchers support path parameters and regex-like syntax.

export const config = {
  matcher: [
    "/dashboard/:path*",    // /dashboard and all sub-paths
    "/api/:path*",          // /api and all sub-paths
    "/admin",               // Exact match
  ],
};

The :path* syntax matches zero or more path segments. :path+ matches one or more. You can also use named parameters like :slug for a single segment.

Redirects

Redirects send the client to a different URL with a 3xx status code. Use them for moved pages, authentication gates, or locale routing.

import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";

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

  // Permanent redirect (308)
  if (pathname === "/old-blog") {
    return NextResponse.redirect(new URL("/blog", request.url), 308);
  }

  // Temporary redirect (307) — default
  if (pathname === "/promo") {
    return NextResponse.redirect(new URL("/summer-sale", request.url));
  }

  // Redirect with query parameters preserved
  if (pathname.startsWith("/legacy/")) {
    const newPath = pathname.replace("/legacy/", "/v2/");
    const url = request.nextUrl.clone();
    url.pathname = newPath;
    return NextResponse.redirect(url);
  }

  return NextResponse.next();
}

Always use new URL(path, request.url) or request.nextUrl.clone() to construct redirect URLs. This preserves the protocol and host correctly in both development and production.

Rewrites

Rewrites serve content from a different path without changing the URL in the browser. They are invisible to the user.

import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";

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

  // A/B testing: rewrite to variant page without changing the URL
  if (pathname === "/landing") {
    const variant = request.cookies.get("ab-variant")?.value || "a";
    return NextResponse.rewrite(
      new URL(`/landing-${variant}`, request.url)
    );
  }

  // Multi-tenant: route based on subdomain
  const hostname = request.headers.get("host") || "";
  const subdomain = hostname.split(".")[0];

  if (subdomain !== "www" && subdomain !== "localhost") {
    return NextResponse.rewrite(
      new URL(`/tenants/${subdomain}${pathname}`, request.url)
    );
  }

  return NextResponse.next();
}

Rewrites are powerful for multi-tenant architectures, A/B testing, and feature flags. The user sees the original URL while your app serves content from a different internal route.

Modifying headers

Middleware can add, modify, or remove both request and response headers.

import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";

export function middleware(request: NextRequest) {
  // Add request headers (visible to route handlers)
  const requestHeaders = new Headers(request.headers);
  requestHeaders.set("x-request-id", crypto.randomUUID());
  requestHeaders.set("x-pathname", request.nextUrl.pathname);

  const response = NextResponse.next({
    request: {
      headers: requestHeaders,
    },
  });

  // Add response headers (visible to the client)
  response.headers.set("x-middleware-version", "1.0");
  response.headers.set(
    "strict-transport-security",
    "max-age=63072000; includeSubDomains; preload"
  );
  response.headers.set("x-content-type-options", "nosniff");
  response.headers.set("x-frame-options", "DENY");

  return response;
}

Request headers set in middleware are accessible in server components and route handlers via headers().

Authentication pattern

The most common middleware use case is protecting routes based on authentication status.

import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { jwtVerify } from "jose";

const PUBLIC_PATHS = ["/", "/login", "/register", "/about"];
const AUTH_COOKIE = "auth-token";
const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET!);

async function verifyToken(token: string) {
  try {
    const { payload } = await jwtVerify(token, JWT_SECRET);
    return payload;
  } catch {
    return null;
  }
}

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

  // Allow public paths
  if (PUBLIC_PATHS.some((path) => pathname === path)) {
    return NextResponse.next();
  }

  // Allow static assets
  if (pathname.startsWith("/_next/") || pathname.includes(".")) {
    return NextResponse.next();
  }

  const token = request.cookies.get(AUTH_COOKIE)?.value;

  if (!token) {
    const loginUrl = new URL("/login", request.url);
    loginUrl.searchParams.set("redirect", pathname);
    return NextResponse.redirect(loginUrl);
  }

  const payload = await verifyToken(token);

  if (!payload) {
    // Token is invalid or expired
    const response = NextResponse.redirect(new URL("/login", request.url));
    response.cookies.delete(AUTH_COOKIE);
    return response;
  }

  // Add user info to request headers for downstream use
  const requestHeaders = new Headers(request.headers);
  requestHeaders.set("x-user-id", payload.sub as string);
  requestHeaders.set("x-user-role", payload.role as string);

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

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

Note the use of jose instead of jsonwebtoken. The edge runtime does not support Node.js-specific modules. jose is designed for edge environments and uses the Web Crypto API.

Role-based authorization

Extending the authentication pattern to support role-based access control.

import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";

const ROLE_ROUTES: Record<string, string[]> = {
  "/admin": ["admin"],
  "/admin/users": ["admin"],
  "/dashboard/billing": ["admin", "billing"],
  "/dashboard/reports": ["admin", "manager", "analyst"],
};

function getUserRole(request: NextRequest): string | null {
  return request.cookies.get("user-role")?.value || null;
}

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

  // Find the most specific matching route
  const matchedRoute = Object.keys(ROLE_ROUTES)
    .filter((route) => pathname.startsWith(route))
    .sort((a, b) => b.length - a.length)[0];

  if (matchedRoute) {
    const allowedRoles = ROLE_ROUTES[matchedRoute];

    if (!role || !allowedRoles.includes(role)) {
      return NextResponse.redirect(new URL("/unauthorized", request.url));
    }
  }

  return NextResponse.next();
}

Geolocation-based routing

On platforms like Vercel, the edge runtime provides geolocation data in the request object.

import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";

const EU_COUNTRIES = [
  "AT", "BE", "BG", "HR", "CY", "CZ", "DK", "EE", "FI", "FR",
  "DE", "GR", "HU", "IE", "IT", "LV", "LT", "LU", "MT", "NL",
  "PL", "PT", "RO", "SK", "SI", "ES", "SE",
];

export function middleware(request: NextRequest) {
  const country = request.geo?.country || "US";
  const city = request.geo?.city || "Unknown";

  // GDPR consent banner for EU visitors
  if (EU_COUNTRIES.includes(country)) {
    const response = NextResponse.next();
    response.headers.set("x-show-gdpr-banner", "true");
    response.headers.set("x-user-country", country);
    return response;
  }

  // Region-specific pricing pages
  const { pathname } = request.nextUrl;
  if (pathname === "/pricing") {
    const region = country === "US" ? "us" : country === "GB" ? "uk" : "intl";
    return NextResponse.rewrite(new URL(`/pricing/${region}`, request.url));
  }

  return NextResponse.next();
}

Rate limiting

Basic rate limiting in middleware using an in-memory store. For production, use a distributed store like Redis via an edge-compatible client.

import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";

const rateLimit = new Map<string, { count: number; resetTime: number }>();

const WINDOW_MS = 60 * 1000; // 1 minute
const MAX_REQUESTS = 100;

function getRateLimitKey(request: NextRequest): string {
  const forwarded = request.headers.get("x-forwarded-for");
  const ip = forwarded?.split(",")[0]?.trim() || "unknown";
  return `${ip}:${request.nextUrl.pathname}`;
}

export function middleware(request: NextRequest) {
  if (!request.nextUrl.pathname.startsWith("/api/")) {
    return NextResponse.next();
  }

  const key = getRateLimitKey(request);
  const now = Date.now();
  const record = rateLimit.get(key);

  if (!record || now > record.resetTime) {
    rateLimit.set(key, { count: 1, resetTime: now + WINDOW_MS });
    const response = NextResponse.next();
    response.headers.set("x-ratelimit-limit", String(MAX_REQUESTS));
    response.headers.set("x-ratelimit-remaining", String(MAX_REQUESTS - 1));
    return response;
  }

  if (record.count >= MAX_REQUESTS) {
    return NextResponse.json(
      { error: "Too many requests" },
      {
        status: 429,
        headers: {
          "retry-after": String(Math.ceil((record.resetTime - now) / 1000)),
          "x-ratelimit-limit": String(MAX_REQUESTS),
          "x-ratelimit-remaining": "0",
        },
      }
    );
  }

  record.count++;
  const response = NextResponse.next();
  response.headers.set("x-ratelimit-limit", String(MAX_REQUESTS));
  response.headers.set(
    "x-ratelimit-remaining",
    String(MAX_REQUESTS - record.count)
  );
  return response;
}

export const config = {
  matcher: "/api/:path*",
};

Note that in-memory rate limiting only works on a single edge instance. In a distributed deployment, requests are spread across multiple instances and each has its own counter. Use a shared store for accurate rate limiting.

Middleware can read, set, and delete cookies on both the request and response.

import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";

export function middleware(request: NextRequest) {
  // Read a cookie
  const theme = request.cookies.get("theme")?.value || "light";
  const visitCount = parseInt(
    request.cookies.get("visit-count")?.value || "0",
    10
  );

  const response = NextResponse.next();

  // Set cookies on the response
  response.cookies.set("visit-count", String(visitCount + 1), {
    httpOnly: true,
    secure: process.env.NODE_ENV === "production",
    sameSite: "lax",
    maxAge: 60 * 60 * 24 * 365, // 1 year
    path: "/",
  });

  // Set a session cookie (no maxAge = session cookie)
  if (!request.cookies.has("session-id")) {
    response.cookies.set("session-id", crypto.randomUUID(), {
      httpOnly: true,
      secure: true,
      sameSite: "strict",
    });
  }

  return response;
}

Composing multiple middleware concerns

Since Next.js only supports a single middleware file, you need to compose multiple concerns within it. A clean approach is to use a chain of handlers.

import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";

type MiddlewareHandler = (
  request: NextRequest
) => NextResponse | Response | null | Promise<NextResponse | Response | null>;

const handlers: MiddlewareHandler[] = [
  // 1. Security headers
  (request) => {
    return null; // Continue to next handler
  },

  // 2. Authentication
  async (request) => {
    if (request.nextUrl.pathname.startsWith("/dashboard")) {
      const token = request.cookies.get("token")?.value;
      if (!token) {
        return NextResponse.redirect(new URL("/login", request.url));
      }
    }
    return null;
  },

  // 3. Locale detection
  (request) => {
    if (request.nextUrl.pathname === "/") {
      const locale = request.headers.get("accept-language")?.split(",")[0]?.split("-")[0];
      if (locale === "fr") {
        return NextResponse.rewrite(new URL("/fr", request.url));
      }
    }
    return null;
  },
];

export async function middleware(request: NextRequest) {
  for (const handler of handlers) {
    const result = await handler(request);
    if (result) return result;
  }

  const response = NextResponse.next();
  response.headers.set("x-content-type-options", "nosniff");
  response.headers.set("x-frame-options", "DENY");
  return response;
}

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

Edge runtime limitations

Middleware runs in the edge runtime, which is a subset of the Web APIs. Key limitations to keep in mind:

  • No fs module or file system access
  • No native Node.js modules (crypto from Node, Buffer global — use TextEncoder instead)
  • Limited fetch (no HTTP/2, no custom agents)
  • No long-running processes or WebSocket upgrades
  • Response body streaming is supported but has platform-specific behavior
  • Maximum execution time varies by platform (Vercel: 30 seconds on Pro)
// These work in edge runtime
const id = crypto.randomUUID();
const encoded = new TextEncoder().encode("hello");
const response = await fetch("https://api.example.com/data");

// These do NOT work
// import fs from "fs";
// import { createHash } from "crypto";
// const buf = Buffer.from("hello");

Choose the edge runtime for middleware that needs low latency and operates on headers, cookies, and simple logic. If your middleware needs heavy computation or Node.js APIs, consider moving that logic to route handlers or server components instead.