Skip to content
C Codeloom
Next.js

Next.js Middleware: Auth, Redirects, and Edge Logic

Learn how to use Next.js middleware for authentication gates, locale routing, A/B tests, and edge logic with matcher config and runtime caveats.

·6 min read · By Yash Kesharwani
Intermediate 9 min read

What you'll learn

  • How middleware.ts intercepts requests before they hit a route
  • Writing a matcher config to target specific paths
  • Implementing an auth gate that redirects unauthenticated users
  • Handling locale detection and A/B test cookie assignment
  • Edge runtime limits you should know before shipping

Prerequisites

  • Familiarity with the App Router: [Next.js App Router Basics](/blog/nextjs-app-router-basics)
  • Comfort reading server code: [Next.js Server Components](/blog/nextjs-server-components)

Middleware is code that runs on every matching request before Next.js decides what to render. It lives in a single middleware.ts at the project root (or inside src/) and is designed for short, fast tasks: checking a cookie, rewriting a URL, adding a header, or redirecting away from a private page. It is not the place to query a database or run heavy logic.

Where middleware fits in the request lifecycle

When a request arrives, Next.js first checks if the path matches your middleware. If it does, middleware runs. You can then continue the request, rewrite it to another path, redirect to a different URL, or respond directly. Anything you do here happens before the App Router resolves which segment to render, which is what makes middleware ideal for cross-cutting concerns.

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

export function middleware(request: NextRequest) {
  const response = NextResponse.next();
  response.headers.set('x-request-id', crypto.randomUUID());
  return response;
}

This minimal middleware tags every request with a unique ID. The NextResponse.next() call says “continue to the actual route” while letting you mutate headers on the way through.

The matcher config

By default, middleware runs on every route. That is rarely what you want. The matcher config narrows the scope, which both improves performance and reduces the surface area for bugs.

// middleware.ts
export const config = {
  matcher: [
    '/dashboard/:path*',
    '/account/:path*',
    '/((?!api|_next/static|_next/image|favicon.ico).*)',
  ],
};

The first two patterns target specific app sections. The third is a common pattern that excludes API routes and static assets, which usually do not need middleware logic.

Pattern 1: an authentication gate

The most common use case is redirecting unauthenticated users away from private pages. Read a session cookie, check if it is present, and redirect when it is missing.

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

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

  if (!session) {
    const loginUrl = new URL('/login', request.url);
    loginUrl.searchParams.set('redirect', request.nextUrl.pathname);
    return NextResponse.redirect(loginUrl);
  }

  return NextResponse.next();
}

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

Notice we preserve the original path in a redirect query parameter so the login page can send the user back after sign-in. Middleware should only check that the cookie exists and is well-formed. The full session lookup belongs in a server component or server action where you can query your database.

Pattern 2: locale detection

For internationalized sites, middleware can detect the user’s preferred language from the Accept-Language header or a stored cookie, then rewrite the URL to the localized variant.

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

const SUPPORTED = ['en', 'es', 'fr'];
const DEFAULT_LOCALE = 'en';

function pickLocale(request: NextRequest): string {
  const cookieLocale = request.cookies.get('locale')?.value;
  if (cookieLocale && SUPPORTED.includes(cookieLocale)) return cookieLocale;

  const header = request.headers.get('accept-language') ?? '';
  const preferred = header.split(',')[0]?.split('-')[0];
  return SUPPORTED.includes(preferred ?? '') ? preferred! : DEFAULT_LOCALE;
}

export function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl;
  const hasLocale = SUPPORTED.some(
    (l) => pathname.startsWith(`/${l}/`) || pathname === `/${l}`,
  );
  if (hasLocale) return NextResponse.next();

  const locale = pickLocale(request);
  return NextResponse.redirect(new URL(`/${locale}${pathname}`, request.url));
}

This middleware sends users without a locale prefix to one chosen from cookie or browser preferences.

Pattern 3: A/B testing

You can flip users into a test cohort by setting a cookie on first request and rewriting them to a variant page.

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

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

  let bucket = request.cookies.get('ab-pricing')?.value;
  if (bucket !== 'A' && bucket !== 'B') {
    bucket = Math.random() < 0.5 ? 'A' : 'B';
  }

  const url = request.nextUrl.clone();
  url.pathname = bucket === 'B' ? '/pricing-variant' : '/pricing';

  const response = NextResponse.rewrite(url);
  response.cookies.set('ab-pricing', bucket, { path: '/', maxAge: 60 * 60 * 24 * 30 });
  return response;
}

export const config = { matcher: '/pricing' };

rewrite keeps the URL bar at /pricing while serving a different page, which is exactly what you want for a clean test.

Edge runtime caveats

Middleware runs on the Edge runtime by default. That means a small, fast V8 environment with limits worth understanding before you ship.

  • No Node.js APIs like fs, child_process, or native crypto modules. Use Web Crypto via crypto.subtle.
  • Cold starts are fast but bundle size is constrained. Keep middleware lean.
  • Database drivers that need TCP sockets will not work. Use HTTP-based clients or move the lookup into a server component. See Next.js data fetching patterns for where heavier work belongs.
  • The response body is limited. Middleware is for routing decisions, not for streaming large payloads.

If you genuinely need Node APIs, you can opt into the Node.js runtime, but the simpler answer is usually to keep middleware focused on cookies, headers, and URL decisions and let the route handler do the real work.

Ordering and composition

There is only one middleware.ts per project. If you have several concerns, compose them into a single pipeline.

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

function withRequestId(req: NextRequest, res: NextResponse) {
  res.headers.set('x-request-id', crypto.randomUUID());
  return res;
}

function requireAuth(req: NextRequest): NextResponse | null {
  if (!req.nextUrl.pathname.startsWith('/dashboard')) return null;
  if (!req.cookies.get('session')) {
    return NextResponse.redirect(new URL('/login', req.url));
  }
  return null;
}

export function middleware(request: NextRequest) {
  const authResponse = requireAuth(request);
  if (authResponse) return authResponse;

  return withRequestId(request, NextResponse.next());
}

Small, named functions make it obvious what each step does and let you reorder concerns without rewriting the whole file.

Wrap up

Middleware is the right tool for edge-speed decisions: gating private routes, choosing a locale, picking an A/B variant, and tagging requests with diagnostic headers. Keep it small, scope it tightly with matcher, and remember that the Edge runtime is not a place for database queries. For deeper data work, hand off to a server component or route handler. Once you internalize that boundary, middleware becomes one of the most leverage-heavy files in your Next.js app.