Next.js Middleware: Everything You Can Do Before a Request Reaches Your Page
Middleware runs at the edge before your page renders — making it the most powerful and least-understood feature in Next.js. Here's what you can build with it.
Nextcraft Engineering Team
What Middleware Is
Next.js Middleware is a function that runs before a request is matched to a route. It runs on Vercel's Edge Network (or your CDN/server edge), which means it executes geographically close to the user — without cold starts.
The key constraint: Middleware runs in the Edge Runtime. No Node.js APIs, no native modules, no direct database access (unless via HTTP-based clients). This constraint is what makes it fast.
// middleware.ts (in root of project)
import { NextRequest, NextResponse } from 'next/server';
export function middleware(request: NextRequest) {
return NextResponse.next(); // Pass through — do nothing
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
};
The matcher config controls which paths trigger your middleware. Keep it specific — running middleware on /_next/static files serves no purpose and adds latency.
Authentication at the Edge
The most common middleware use case: check for an auth session before serving protected pages.
With a JWT-based session (the session is in a cookie, verifiable without a database call):
import { NextRequest, NextResponse } from 'next/server';
import { jwtVerify } from 'jose';
const protectedPaths = ['/dashboard', '/settings', '/billing'];
export async function middleware(request: NextRequest) {
const isProtected = protectedPaths.some(path =>
request.nextUrl.pathname.startsWith(path)
);
if (!isProtected) return NextResponse.next();
const token = request.cookies.get('session')?.value;
if (!token) {
return NextResponse.redirect(new URL('/login', request.url));
}
try {
await jwtVerify(token, new TextEncoder().encode(process.env.JWT_SECRET));
return NextResponse.next();
} catch {
const response = NextResponse.redirect(new URL('/login', request.url));
response.cookies.delete('session');
return response;
}
}
With Clerk (verifies at edge without a database round-trip):
import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server';
const isProtected = createRouteMatcher(['/dashboard(.*)', '/settings(.*)']);
export default clerkMiddleware((auth, req) => {
if (isProtected(req)) auth().protect();
});
export const config = { matcher: ['/((?!.*\\..*|_next).*)'] };
The advantage of edge auth: the redirect happens before your page even starts rendering — no flash of protected content, no wasted server-side rendering.
Geo-Based Routing
Vercel populates request.geo with the user's geographic data:
export function middleware(request: NextRequest) {
const country = request.geo?.country;
// Redirect to localized version
if (country === 'DE' && !request.nextUrl.pathname.startsWith('/de')) {
return NextResponse.redirect(new URL(`/de${request.nextUrl.pathname}`, request.url));
}
if (country === 'FR' && !request.nextUrl.pathname.startsWith('/fr')) {
return NextResponse.redirect(new URL(`/fr${request.nextUrl.pathname}`, request.url));
}
return NextResponse.next();
}
Or, forward the country as a header for the page to use without redirecting:
export function middleware(request: NextRequest) {
const response = NextResponse.next();
response.headers.set('x-user-country', request.geo?.country ?? 'US');
return response;
}
// In a Server Component, read the forwarded header
import { headers } from 'next/headers';
export default async function PricingPage() {
const country = (await headers()).get('x-user-country') ?? 'US';
const currency = country === 'GB' ? 'GBP' : 'USD';
return <PricingTable currency={currency} />;
}
A/B Testing at the Edge
Assign users to test variants at the edge, with zero performance cost:
import { NextRequest, NextResponse } from 'next/server';
export function middleware(request: NextRequest) {
// Check for existing variant assignment
let variant = request.cookies.get('ab-homepage')?.value;
if (!variant) {
// Assign variant (50/50 split)
variant = Math.random() < 0.5 ? 'control' : 'treatment';
}
// Rewrite to the variant URL without changing the browser URL
const url = request.nextUrl.clone();
if (request.nextUrl.pathname === '/') {
url.pathname = variant === 'treatment' ? '/landing-b' : '/landing-a';
}
const response = NextResponse.rewrite(url);
// Persist the variant in a cookie
if (!request.cookies.has('ab-homepage')) {
response.cookies.set('ab-homepage', variant, { maxAge: 60 * 60 * 24 * 30 });
}
return response;
}
Users see the same URL in their browser but get different page content. The variant cookie ensures they see the same variant on subsequent visits.
Rate Limiting at the Edge
Basic rate limiting without a database (using cookies for state):
export function middleware(request: NextRequest) {
if (!request.nextUrl.pathname.startsWith('/api/')) {
return NextResponse.next();
}
// For real rate limiting, use Upstash Redis — HTTP-based, works at edge
// This example shows the pattern; production needs distributed state
const rateLimit = request.cookies.get('rate-limit-count');
const count = parseInt(rateLimit?.value ?? '0', 10);
if (count > 100) {
return new NextResponse('Too Many Requests', {
status: 429,
headers: { 'Retry-After': '60' },
});
}
const response = NextResponse.next();
response.cookies.set('rate-limit-count', String(count + 1), { maxAge: 60 });
return response;
}
For production rate limiting, use Upstash Redis — it's an HTTP-native Redis that works in the Edge Runtime.
Security Headers
Add security headers to every response without touching individual pages:
const securityHeaders = {
'X-Frame-Options': 'SAMEORIGIN',
'X-Content-Type-Options': 'nosniff',
'Referrer-Policy': 'strict-origin-when-cross-origin',
'Permissions-Policy': 'camera=(), microphone=(), geolocation=()',
'Strict-Transport-Security': 'max-age=31536000; includeSubDomains',
};
export function middleware(request: NextRequest) {
const response = NextResponse.next();
Object.entries(securityHeaders).forEach(([key, value]) => {
response.headers.set(key, value);
});
return response;
}
Note: you can also set security headers in next.config.ts via the headers() function. The middleware approach is better when headers depend on request context; the config approach is better for global, unconditional headers.
What Not to Do in Middleware
- Don't call your database directly — use HTTP-based clients (Neon serverless, Upstash, PlanetScale HTTP) or verify sessions via JWT
- Don't run heavy computation — middleware must be fast; anything over 100ms defeats the purpose
- Don't match too broadly — every matched path runs the middleware; unnecessary matches add latency to your static assets
- Don't use Node.js APIs — they're not available in the Edge Runtime
Middleware is a sharp tool: powerful when used for its specific purpose (request interception, fast edge logic), problematic when overloaded with concerns that belong in Server Components or API routes.
Continue reading
Related articles
Why Next.js App Router Is Better for SEO Than Pages Router
The App Router isn't just a new file-system convention — it fundamentally changes how search engines crawl and index your Next.js application.
EngineeringServer Components vs Client Components: Making the Right Call
The boundary between Server and Client Components is the most consequential architectural decision you make in a Next.js application. Here's how to draw it correctly.
EngineeringBuilding High-Performance Next.js Applications for Scale
A deep dive into how we utilize App Router and React Server Components to scale our client builds effectively.
Stay Informed.
Join 1,200+ founders and engineers receiving our monthly deep dives on product engineering, design, and growth.