Middleware
Middleware runs before every matched request, enabling redirects, rewrites, header modifications, and authentication checks at the edge.
Search across all documentation pages
Middleware runs before every matched request, enabling redirects, rewrites, header modifications, and authentication checks at the edge.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
Quick-reference recipe card - copy-paste ready.
// middleware.ts (root of project, same level as app/)
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export function middleware(request: NextRequest) {
// Redirect, rewrite, or modify headers
return NextResponse.next();
}
export const config = {
matcher: ["/dashboard/:path*", "/api/:path*"],
};When to reach for this: Authentication guards, locale detection, A/B testing, redirects, and any request-level logic that must run before rendering.
// middleware.ts - Auth guard with locale detection
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
const protectedPaths = ["/dashboard", "/settings", "/billing"];
const defaultLocale = "en";
const supportedLocales = ["en", "fr", "de", "es"];
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// 1. Locale detection - redirect if no locale prefix
const pathnameHasLocale = supportedLocales.some(
(locale) => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`
);
if (!pathnameHasLocale) {
const locale =
request.headers.get("accept-language")?.split(",")[0]?.split("-")[0] ?? defaultLocale;
const detectedLocale = supportedLocales.includes(locale) ? locale : defaultLocale;
return NextResponse.redirect(
new URL(`/${detectedLocale}${pathname}`, request.url)
);
}
// 2. Auth check - redirect unauthenticated users
const isProtected = protectedPaths.some((path) =>
pathname.includes(path)
);
const token = request.cookies.get("session-token")?.value;
if (isProtected && !token) {
const loginUrl = new URL("/en/login", request.url);
loginUrl.searchParams.set("callbackUrl", pathname);
return NextResponse.redirect(loginUrl);
}
// 3. Add custom headers
const response = NextResponse.next();
response.headers.set("x-pathname", pathname);
return response;
}
export const config = {
matcher: [
// Match all paths except static files and Next.js internals
"/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
],
};// middleware.ts - Simple redirect map
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
const redirects: Record<string, string> = {
"/old-blog": "/blog",
"/docs/v1": "/docs",
"/legacy-api": "/api/v2",
};
export function middleware(request: NextRequest) {
const redirect = redirects[request.nextUrl.pathname];
if (redirect) {
return NextResponse.redirect(new URL(redirect, request.url), 301);
}
return NextResponse.next();
}
export const config = {
matcher: ["/old-blog", "/docs/v1", "/legacy-api"],
};middleware.ts file per project. It must be at the root (next to app/ or src/). You cannot have per-route middleware files.matcher config filters which routes trigger middleware. Without a matcher, middleware runs on every request including static assets.NextResponse.next() continues to the matched route. You can modify request/response headers while passing through.NextResponse.redirect(url) sends a redirect response (302 by default, pass 301 for permanent).NextResponse.rewrite(url) serves a different route's content without changing the browser URL.fs, path, and most npm packages are unavailable. Use only Web APIs.request.cookies.get() and response.cookies.set() for session management.// Rewrite-based A/B testing
export function middleware(request: NextRequest) {
const bucket = request.cookies.get("ab-bucket")?.value;
const response = NextResponse.next();
if (!bucket) {
const newBucket = Math.random() > 0.5 ? "a" : "b";
response.cookies.set("ab-bucket", newBucket, { maxAge: 60 * 60 * 24 * 30 });
}
const currentBucket = bucket ?? "a";
if (request.nextUrl.pathname === "/pricing") {
return NextResponse.rewrite(
new URL(`/pricing/${currentBucket}`, request.url)
);
}
return response;
}// Rate limiting with headers
export function middleware(request: NextRequest) {
if (request.nextUrl.pathname.startsWith("/api/")) {
const ip = request.headers.get("x-forwarded-for") ?? "unknown";
// Check rate limit (using external store like Upstash Redis)
// This is pseudocode - use @upstash/ratelimit in production
const response = NextResponse.next();
response.headers.set("X-RateLimit-Limit", "100");
return response;
}
return NextResponse.next();
}// Matcher patterns
export const config = {
matcher: [
"/dashboard/:path*", // /dashboard and all sub-paths
"/api/:path*", // All API routes
"/blog/:slug", // Single dynamic segment
"/((?!_next|static|api).*)", // Everything except _next, static, api
],
};import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
// Middleware function signature
export function middleware(request: NextRequest): NextResponse | Response | undefined;
// NextRequest extends the Web Request API
// Key properties:
// request.nextUrl - parsed URL with pathname, searchParams
// request.cookies - RequestCookies instance
// request.headers - standard Headers object
// request.geo - { city, country, region } (Vercel only)
// request.ip - client IP (Vercel only)
// Config type
export const config: {
matcher: string | string[];
};request.nextUrl.pathname inside a single middleware function.bcrypt, fs, or database drivers will not work - use edge-compatible alternatives.matcher. Always configure a matcher to avoid unnecessary execution./login to /en/login and middleware also matches /en/login, you get a loop. Exclude destination paths from the matcher.NextResponse.next() does not short-circuit. Other middleware logic after it still runs. Return early if needed.cookies() from next/headers.| Approach | When to Use |
|---|---|
next.config.js redirects | Static redirect rules that do not need runtime logic |
next.config.js rewrites | Static rewrite rules without conditional logic |
Route-level redirect() | Redirecting inside a Server Component after data checks |
| Server Actions with auth | Protecting mutations instead of routes |
| Layout-level auth guards | Checking auth in a layout and redirecting |
From a production Next.js 15 / React 19 SaaS application (SystemsArchitect.io).
// Production example: Middleware with rewrites, redirects, session refresh, and admin protection
// File: middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { updateSession } from '@/utils/supabase/middleware';
const legacyRedirects: Record<string, string> = {
'/old-docs': '/docs',
'/blog/legacy-post': '/articles',
'/pricing-old': '/pricing',
};
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// 1. Docs rewrite: serve /docs/[section] from a different internal route
if (pathname.startsWith('/docs/')) {
const url = request.nextUrl.clone();
url.pathname = `/docs-internal${pathname.replace('/docs', '')}`;
return NextResponse.rewrite(url);
}
// 2. Legacy redirects (301 permanent)
const redirect = legacyRedirects[pathname];
if (redirect) {
return NextResponse.redirect(new URL(redirect, request.url), 301);
}
// 3. Session refresh - keep Supabase auth session alive
const response = await updateSession(request);
// 4. Admin route protection
if (pathname.startsWith('/admin')) {
const session = request.cookies.get('sb-access-token')?.value;
if (!session) {
return NextResponse.redirect(new URL('/login', request.url));
}
}
return response;
}
export const config = {
matcher: [
'/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
],
};What this demonstrates in production:
301 status code on legacy redirects tells search engines the move is permanent. Using the default 302 would preserve the old URL in search indexes.updateSession(request) is a Supabase utility that refreshes the auth session cookie on every request. This prevents the session from expiring while the user is actively browsing./((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*) excludes static assets and images from middleware processing. Without this, middleware runs on every image and CSS file request, adding unnecessary latency.middleware.ts file per project. All route-specific logic must be handled with conditional checks on pathname.At the project root, next to the app/ or src/ directory. There is only one middleware.ts file per project. You cannot have per-route middleware files.
fs, path, and most npm packages are unavailableMiddleware runs on every request, including static assets like images, CSS, and JavaScript files. Always configure a matcher to avoid unnecessary execution.
If you redirect /login to /en/login and middleware also matches /en/login, you get an infinite loop. Exclude destination paths from the matcher or add conditional checks to skip already-processed paths.
redirect() sends a redirect response (302 by default, 301 for permanent) and changes the browser URLrewrite() serves a different route's content without changing the browser URLexport function middleware(request: NextRequest) {
// Read a cookie
const token = request.cookies.get("session")?.value;
// Set a cookie on the response
const response = NextResponse.next();
response.cookies.set("visited", "true", { maxAge: 3600 });
return response;
}Yes. Cookies set in middleware are available via cookies() from next/headers in Server Components.
Use conditional checks on request.nextUrl.pathname inside the middleware function, since you can only have one middleware file.
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
if (pathname.startsWith("/api/")) { /* API logic */ }
if (pathname.startsWith("/admin")) { /* admin logic */ }
return NextResponse.next();
}import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export function middleware(
request: NextRequest
): NextResponse | Response | undefined;
export const config: {
matcher: string | string[];
};request.nextUrl -- parsed URL with pathname and searchParamsrequest.cookies -- RequestCookies instancerequest.headers -- standard Headers objectrequest.geo -- city, country, region (Vercel only)request.ip -- client IP (Vercel only)No. Code after NextResponse.next() still runs. If you want to stop processing, you must explicitly return the response.
next.config.js redirects for static rules that do not need runtime logicredirect() and programmatic navigationReviewed by Chris St. John·Last updated Jul 10, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥