All insights
Engineering6 min read

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.

NC

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.

code
// 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):

code
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):

code
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:

code
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:

code
export function middleware(request: NextRequest) {
  const response = NextResponse.next();
  response.headers.set('x-user-country', request.geo?.country ?? 'US');
  return response;
}
code
// 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:

code
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):

code
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:

code
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.

Stay Informed.

Join 1,200+ founders and engineers receiving our monthly deep dives on product engineering, design, and growth.

Insights once a month. No spam. Unsubscribe anytime.