Middleware in Next.js runs before a request is completed — at the edge, before the page renders, before your API route executes. This makes it the right place for logic that needs to run on every request: authentication checks, redirects, geo-based routing, and A/B testing.
Here's the complete picture of what middleware is actually good for, with patterns from the request flow at free AI product image generation.
How Middleware Works
Create middleware.ts at the root of your project (same level as app/). Next.js automatically runs it before matched routes.
// middleware.ts
import { NextRequest, NextResponse } from 'next/server';
export function middleware(request: NextRequest) {
// Runs before every matched request
return NextResponse.next(); // Continue to the route
}
export const config = {
matcher: [
// Which paths to run middleware on
'/((?!_next/static|_next/image|favicon.ico).*)',
],
};
The config.matcher controls which paths trigger middleware. The pattern above runs on all paths except Next.js internals. Be specific here — middleware runs at the edge on every request, so unintentionally broad matchers create unnecessary latency.
Auth Guards — Protecting Routes
The most common middleware use case: redirect unauthenticated users away from protected routes.
// middleware.ts
import { NextRequest, NextResponse } from 'next/server';
const PROTECTED_ROUTES = ['/dashboard', '/account', '/settings'];
const LOGIN_PAGE = '/login';
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
const isProtectedRoute = PROTECTED_ROUTES.some(route =>
pathname.startsWith(route)
);
if (!isProtectedRoute) {
return NextResponse.next();
}
// Check for auth token in cookies
const token = request.cookies.get('auth-token')?.value;
if (!token) {
const loginUrl = new URL(LOGIN_PAGE, request.url);
loginUrl.searchParams.set('from', pathname);
return NextResponse.redirect(loginUrl);
}
return NextResponse.next();
}
export const config = {
matcher: ['/dashboard/:path*', '/account/:path*', '/settings/:path*'],
};
The matcher config here is more specific — only runs on the protected route groups rather than every request. Faster and cleaner.
For JWT validation in middleware:
import { jwtVerify } from 'jose';
async function verifyToken(token: string): Promise<boolean> {
try {
const secret = new TextEncoder().encode(process.env.JWT_SECRET!);
await jwtVerify(token, secret);
return true;
} catch {
return false;
}
}
export async function middleware(request: NextRequest) {
const token = request.cookies.get('auth-token')?.value;
if (!token || !(await verifyToken(token))) {
return NextResponse.redirect(new URL('/login', request.url));
}
return NextResponse.next();
}
Note: keep token verification lightweight in middleware. Heavy database lookups don't belong here — middleware runs on every matched request and latency adds up.
Redirects at Scale
For simple redirects, next.config.js redirects are better. Middleware redirects make sense when the redirect logic is conditional or dynamic.
// middleware.ts — conditional redirect based on request properties
export function middleware(request: NextRequest) {
const { pathname, searchParams } = request.nextUrl;
// Redirect old URL patterns to new ones
if (pathname.startsWith('/blog/post/')) {
const slug = pathname.replace('/blog/post/', '');
return NextResponse.redirect(
new URL(`/blog/${slug}`, request.url),
{ status: 301 }
);
}
// Redirect based on query param
if (searchParams.get('ref') === 'old-campaign') {
const url = request.nextUrl.clone();
url.searchParams.delete('ref');
url.searchParams.set('utm_source', 'old-campaign');
return NextResponse.redirect(url);
}
return NextResponse.next();
}
Geo-Based Routing
Middleware receives geo data from the edge (Vercel populates this automatically):
export function middleware(request: NextRequest) {
const country = request.geo?.country || 'US';
// Redirect to localized version
if (country === 'DE' && !request.nextUrl.pathname.startsWith('/de')) {
return NextResponse.redirect(
new URL(`/de${request.nextUrl.pathname}`, request.url)
);
}
return NextResponse.next();
}
A/B Testing With Middleware
A/B testing at the edge — assigning users to buckets and serving different content — works well with middleware because it runs before the page renders.
// middleware.ts
import { NextRequest, NextResponse } from 'next/server';
const AB_COOKIE = 'ab-homepage';
const VARIANTS = ['control', 'variant-a'] as const;
type Variant = typeof VARIANTS[number];
function getVariant(): Variant {
return Math.random() < 0.5 ? 'control' : 'variant-a';
}
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
if (pathname !== '/') {
return NextResponse.next();
}
// Get or assign variant
let variant = request.cookies.get(AB_COOKIE)?.value as Variant | undefined;
if (!variant || !VARIANTS.includes(variant)) {
variant = getVariant();
}
// Rewrite to variant page (URL stays the same for user)
const response = NextResponse.rewrite(
new URL(`/ab-variants/${variant}`, request.url)
);
// Set cookie so user stays in their bucket
response.cookies.set(AB_COOKIE, variant, {
maxAge: 60 * 60 * 24 * 30, // 30 days
httpOnly: true,
sameSite: 'lax',
});
return response;
}
export const config = {
matcher: ['/'],
};
NextResponse.rewrite shows different content at the same URL — the user sees / but Next.js serves /ab-variants/variant-a. The cookie persists the assignment across sessions.
Adding Headers to Requests
Middleware can add headers that downstream components can read:
export function middleware(request: NextRequest) {
const requestHeaders = new Headers(request.headers);
// Add pathname as header for Server Components to read
requestHeaders.set('x-pathname', request.nextUrl.pathname);
// Add user segment based on cookie
const segment = request.cookies.get('user-segment')?.value || 'unknown';
requestHeaders.set('x-user-segment', segment);
return NextResponse.next({
request: { headers: requestHeaders },
});
}
In a Server Component:
import { headers } from 'next/headers';
export default function Page() {
const pathname = headers().get('x-pathname');
const segment = headers().get('x-user-segment');
return <div data-segment={segment}>Content for {pathname}</div>;
}
What Middleware Is Not For
Middleware runs at the edge with a limited runtime — no Node.js built-ins, no filesystem access, no database connections. It's for fast, lightweight request inspection and modification.
Heavy logic, database queries, and complex business logic belong in API routes or Server Components, not middleware. If you find middleware doing significant processing, that's a sign the logic belongs elsewhere.
The Matcher Config — Getting It Right
The matcher is worth getting right. Every path that matches runs middleware — unnecessary matches mean unnecessary latency.
export const config = {
matcher: [
// Match everything except static files and images
'/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
],
};
For route group matching:
export const config = {
matcher: [
'/dashboard/:path*', // matches /dashboard and /dashboard/anything
'/api/:path*', // matches all API routes
'/((?!login|signup).*)', // matches everything except login and signup
],
};
Test your matchers before deploying. An overly broad matcher that runs middleware on image requests and static files adds latency to every asset load.
Summary
Middleware runs before matched routes at the edge. Use it for: auth guards that redirect unauthenticated users, conditional redirects based on request properties, geo-based routing, A/B testing with cookie-persisted assignments, and adding request headers for Server Components.
Keep it fast and lightweight — no database queries, no heavy computation. The edge runtime is intentionally limited to keep middleware responses fast.
Rate Limiting With Middleware
A common pattern: simple rate limiting at the edge before requests hit your API routes.
// middleware.ts — simple in-memory rate limiting (use Redis for production)
const rateLimitMap = new Map<string, { count: number; resetTime: number }>();
function isRateLimited(ip: string, limit = 10, windowMs = 60000): boolean {
const now = Date.now();
const record = rateLimitMap.get(ip);
if (!record || now > record.resetTime) {
rateLimitMap.set(ip, { count: 1, resetTime: now + windowMs });
return false;
}
if (record.count >= limit) return true;
record.count++;
return false;
}
export function middleware(request: NextRequest) {
if (request.nextUrl.pathname.startsWith('/api/')) {
const ip = request.ip || request.headers.get('x-forwarded-for') || 'unknown';
if (isRateLimited(ip)) {
return new NextResponse('Too Many Requests', { status: 429 });
}
}
return NextResponse.next();
}
For production rate limiting across edge instances, use a shared store like Upstash Redis — in-memory Maps don't persist across serverless invocations.
Top comments (0)