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.
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.
Cookie manipulation
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
fsmodule or file system access - No native Node.js modules (
cryptofrom Node,Bufferglobal — useTextEncoderinstead) - 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.
Related articles
- Next.js Authentication Middleware Patterns in Next.js
Implement authentication middleware in Next.js to protect routes, redirect unauthenticated users, and manage JWT or session-based auth at the edge.
- Next.js Next.js Intercepting Routes for Modals
Build modal patterns in Next.js using intercepting routes. Learn the (.) convention, combining with parallel routes, and handling hard refreshes.
- Next.js Next.js Image Optimization Strategies with next/image
Master the next/image component with advanced optimization strategies including responsive images, blur placeholders, CDN configuration, and performance tips.
- Next.js Next.js Middleware Patterns for Production Apps
Practical middleware patterns for Next.js including authentication, A/B testing, geolocation routing, bot detection, and request logging.