mdashikjs/blog
All posts
Next.js Middleware and Edge Functions in Production
Frontend

Next.js Middleware and Edge Functions in Production

Frontend5 min

Next.js Middleware and Edge Functions in Production

Middleware runs before every request hits your page. At the edge, that means authentication, redirects, and A/B testing happen in under 5ms — before the page even starts rendering.

Next.jsEdge FunctionsMiddlewareAuthentication
Share:

What middleware is

A single function in middleware.ts at your project root that intercepts every request. It runs on the Edge Runtime — a lightweight V8 isolate, not a full Node.js environment.

// middleware.ts
import { NextRequest, NextResponse } from 'next/server';

export function middleware(request: NextRequest) {
  // Runs before every matched request
  return NextResponse.next();
}

export const config = {
  matcher: ['/dashboard/:path*', '/api/:path*'],
};

The matcher limits which routes trigger the middleware. Without it, middleware runs on every request including static assets.

Authentication at the edge

The most common use case. Check for a session token and redirect unauthenticated users:

import { NextRequest, NextResponse } from 'next/server';
import { jwtVerify } from 'jose';

const SECRET = new TextEncoder().encode(process.env.JWT_SECRET!);

export async function middleware(request: NextRequest) {
  const token = request.cookies.get('session')?.value;

  if (!token) {
    return NextResponse.redirect(new URL('/login', request.url));
  }

  try {
    const { payload } = await jwtVerify(token, SECRET);
    const headers = new Headers(request.headers);
    headers.set('x-user-id', payload.sub as string);
    headers.set('x-user-role', payload.role as string);
    return NextResponse.next({ headers });
  } catch {
    return NextResponse.redirect(new URL('/login', request.url));
  }
}

The JWT is verified at the edge. If valid, user info is passed to the page via headers. The page never needs to verify the token itself.

Geolocation-based routing

Edge functions have access to the request's geolocation:

export function middleware(request: NextRequest) {
  const country = request.geo?.country || 'US';

  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 without client-side flicker

export function middleware(request: NextRequest) {
  const bucket = request.cookies.get('ab-bucket')?.value;

  if (!bucket) {
    const newBucket = Math.random() < 0.5 ? 'control' : 'variant';
    const response = NextResponse.next();
    response.cookies.set('ab-bucket', newBucket, { maxAge: 60 * 60 * 24 * 30 });
    return response;
  }

  if (bucket === 'variant' && request.nextUrl.pathname === '/pricing') {
    return NextResponse.rewrite(new URL('/pricing-v2', request.url));
  }

  return NextResponse.next();
}

The rewrite is transparent — the URL stays /pricing but the content comes from /pricing-v2. No client-side JavaScript, no layout shift, no flicker.

Edge Runtime limitations

The Edge Runtime is not Node.js. You cannot use:

  • fs, path, or any Node.js built-in modules
  • npm packages that depend on Node.js APIs
  • Long-running processes (middleware has a ~25ms budget on Vercel)

Keep middleware thin. Verify a token, set a header, redirect — that is it. Heavy logic belongs in your API routes or server components.

Rate limiting at the edge

import { NextRequest, NextResponse } from 'next/server';

const rateLimit = new Map<string, { count: number; resetTime: number }>();

export function middleware(request: NextRequest) {
  if (!request.nextUrl.pathname.startsWith('/api')) {
    return NextResponse.next();
  }

  const ip = request.headers.get('x-forwarded-for') || 'unknown';
  const now = Date.now();
  const window = 60000; // 1 minute
  const limit = 100;

  const entry = rateLimit.get(ip);
  if (!entry || now > entry.resetTime) {
    rateLimit.set(ip, { count: 1, resetTime: now + window });
    return NextResponse.next();
  }

  if (entry.count >= limit) {
    return new NextResponse('Too Many Requests', { status: 429 });
  }

  entry.count++;
  return NextResponse.next();
}

Note: this in-memory approach only works for single-instance deployments. For distributed rate limiting, use Upstash Redis or a similar edge-compatible store.

MA

Written by Md Ashik

Senior Software Engineer building reliable backends. I write about the practical tradeoffs behind shipping software that holds up in production.