Next.js Middleware — Authentication, Routing, and Edge Logic

Sanjeev SharmaSanjeev Sharma
6 min read

Advertisement

Introduction

Next.js Middleware runs on the Edge Runtime before a request reaches your route handlers or pages. It intercepts every request that matches the config.matcher pattern and can redirect, rewrite, modify headers, or pass the request through. Because it runs at the edge — close to users, with no cold start — it adds negligible latency while enabling powerful request-level control.

Why This Matters

Without middleware, protecting routes requires checking authentication in every page's server-side code. A single missed check leaves a route exposed. Middleware centralizes this logic: one file, one place, enforced for every request before any page code runs.

Middleware also enables patterns impossible with page-level code: multi-tenant routing based on subdomain, A/B testing via cookie, locale detection and redirect, and rate limiting at the edge. These run in under 1ms because Edge Runtime is globally distributed.

The key constraint: Middleware runs in the Edge Runtime, which supports a subset of Node.js APIs. No file system access, no native modules, and no node: imports. Keep it lightweight.

Creating Middleware

Create middleware.ts in the project root (same level as app/):

// 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: [
    // Match all paths except static files and Next.js internals
    '/((?!_next/static|_next/image|favicon.ico|public/).*)',
  ],
}

Authentication Middleware with JWT

Verify a JWT token on every protected route:

// middleware.ts
import { NextRequest, NextResponse } from 'next/server'
import { jwtVerify } from 'jose'
 
const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET!)
 
const PROTECTED_PATHS = ['/dashboard', '/admin', '/api/protected']
 
export async function middleware(request: NextRequest) {
  const pathname = request.nextUrl.pathname
  const isProtected = PROTECTED_PATHS.some((p) => pathname.startsWith(p))
 
  if (!isProtected) return NextResponse.next()
 
  const token = request.cookies.get('auth-token')?.value
 
  if (!token) {
    const loginUrl = new URL('/login', request.url)
    loginUrl.searchParams.set('from', pathname)
    return NextResponse.redirect(loginUrl)
  }
 
  try {
    const { payload } = await jwtVerify(token, JWT_SECRET)
 
    // Admin-only routes
    if (pathname.startsWith('/admin') && payload.role !== 'admin') {
      return NextResponse.redirect(new URL('/denied', request.url))
    }
 
    // Pass user info to pages via headers
    const response = NextResponse.next()
    response.headers.set('x-user-id', payload.sub as string)
    response.headers.set('x-user-role', payload.role as string)
    return response
  } catch {
    const loginUrl = new URL('/login', request.url)
    loginUrl.searchParams.set('from', pathname)
    return NextResponse.redirect(loginUrl)
  }
}
 
export const config = {
  matcher: ['/dashboard/:path*', '/admin/:path*', '/api/protected/:path*'],
}

Locale Detection and Redirect

Detect user language and redirect to the appropriate locale prefix:

// middleware.ts
import { NextRequest, NextResponse } from 'next/server'
 
const SUPPORTED_LOCALES = ['en', 'es', 'fr', 'de', 'pt']
const DEFAULT_LOCALE = 'en'
 
function detectLocale(request: NextRequest): string {
  // 1. Check cookie preference
  const cookieLocale = request.cookies.get('locale')?.value
  if (cookieLocale && SUPPORTED_LOCALES.includes(cookieLocale)) return cookieLocale
 
  // 2. Check Accept-Language header
  const acceptLang = request.headers.get('accept-language')
  if (acceptLang) {
    const preferred = acceptLang.split(',')[0].split('-')[0].toLowerCase()
    if (SUPPORTED_LOCALES.includes(preferred)) return preferred
  }
 
  return DEFAULT_LOCALE
}
 
export function middleware(request: NextRequest) {
  const pathname = request.nextUrl.pathname
  const hasLocale = SUPPORTED_LOCALES.some(
    (locale) => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`
  )
 
  if (!hasLocale) {
    const locale = detectLocale(request)
    const url = new URL(`/${locale}${pathname}`, request.url)
    url.search = request.nextUrl.search
    return NextResponse.redirect(url)
  }
 
  return NextResponse.next()
}
 
export const config = {
  matcher: ['/((?!_next|api|favicon.ico|public/).*)'],
}

Multi-Tenant Routing by Subdomain

Route requests to tenant-specific pages based on subdomain without changing the URL:

// middleware.ts
import { NextRequest, NextResponse } from 'next/server'
 
export function middleware(request: NextRequest) {
  const hostname = request.headers.get('host') ?? ''
  const pathname = request.nextUrl.pathname
 
  // Extract subdomain: app.example.com → 'app'
  const subdomain = hostname.split('.')[0]
 
  // Skip root domain and www
  if (['localhost', 'example', 'www'].includes(subdomain)) {
    return NextResponse.next()
  }
 
  // Rewrite to /tenants/[subdomain]/... without changing the browser URL
  const url = request.nextUrl.clone()
  url.pathname = `/tenants/${subdomain}${pathname}`
  return NextResponse.rewrite(url)
}
 
export const config = {
  matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
}

Adding Security Headers

// middleware.ts
import { NextRequest, NextResponse } from 'next/server'
 
const SECURITY_HEADERS = {
  'X-Content-Type-Options': 'nosniff',
  'X-Frame-Options': 'DENY',
  'X-XSS-Protection': '1; mode=block',
  'Referrer-Policy': 'strict-origin-when-cross-origin',
  'Permissions-Policy': 'camera=(), microphone=(), geolocation=()',
  'Strict-Transport-Security': 'max-age=63072000; includeSubDomains; preload',
}
 
export function middleware(request: NextRequest) {
  const response = NextResponse.next()
 
  Object.entries(SECURITY_HEADERS).forEach(([key, value]) => {
    response.headers.set(key, value)
  })
 
  return response
}

Edge Rate Limiting

Simple in-memory rate limiting (use Upstash Redis for production):

// middleware.ts
import { NextRequest, NextResponse } from 'next/server'
 
const rateLimitMap = new Map<string, { count: number; resetAt: number }>()
const LIMIT = 100
const WINDOW_MS = 60_000 // 1 minute
 
export function middleware(request: NextRequest) {
  if (!request.nextUrl.pathname.startsWith('/api')) {
    return NextResponse.next()
  }
 
  const ip = request.ip ?? request.headers.get('x-forwarded-for') ?? 'unknown'
  const now = Date.now()
  const entry = rateLimitMap.get(ip)
 
  if (!entry || now > entry.resetAt) {
    rateLimitMap.set(ip, { count: 1, resetAt: now + WINDOW_MS })
    return NextResponse.next()
  }
 
  if (entry.count >= LIMIT) {
    return NextResponse.json(
      { error: 'Rate limit exceeded. Try again in 60 seconds.' },
      { status: 429, headers: { 'Retry-After': '60' } }
    )
  }
 
  entry.count++
  return NextResponse.next()
}

A/B Testing with Cookies

Assign users to experiment variants at the edge:

// middleware.ts
import { NextRequest, NextResponse } from 'next/server'
 
export function middleware(request: NextRequest) {
  const variant = request.cookies.get('ab-variant')?.value
 
  if (!variant) {
    const newVariant = Math.random() &lt; 0.5 ? 'a' : 'b'
    const response = NextResponse.rewrite(
      new URL(`/experiments/${newVariant}${request.nextUrl.pathname}`, request.url)
    )
    response.cookies.set('ab-variant', newVariant, { maxAge: 60 * 60 * 24 * 30 })
    return response
  }
 
  return NextResponse.rewrite(
    new URL(`/experiments/${variant}${request.nextUrl.pathname}`, request.url)
  )
}

Common Mistakes

  • Doing heavy database queries in middleware — the Edge Runtime is not designed for DB connections
  • Forgetting to configure matcher, so middleware runs on every request including static assets
  • Using node: imports — the Edge Runtime does not support Node.js built-ins
  • Not handling the case where a redirect loop occurs (e.g., redirecting /login to /login)
  • Mutating request directly instead of using NextResponse.next() or NextResponse.rewrite()

Best Practices

  • Keep middleware fast and lightweight — under 5ms ideally
  • Use config.matcher to narrow scope; avoid running middleware on _next/static and image paths
  • Pass data from middleware to pages using response.headers.set(), then read with headers() in Server Components
  • For production rate limiting, use Upstash Redis with the @upstash/ratelimit package
  • Use NextResponse.rewrite() to proxy requests without redirecting the browser
  • Test middleware locally with next dev — it runs in the same Edge-like environment

Key Takeaways

  • Middleware runs on the Edge Runtime before any route handler — no cold starts, global distribution
  • It is created in middleware.ts at the project root and exported as a named middleware function
  • config.matcher controls which paths trigger middleware using glob patterns
  • NextResponse.redirect() changes the URL; NextResponse.rewrite() proxies without changing the URL
  • NextResponse.next() passes the request through with optional header modifications
  • The Edge Runtime does not support Node.js built-ins — no fs, no native modules
  • Use middleware for auth guards, locale detection, subdomain routing, and security headers
  • For database-driven logic, pass a session token in a cookie and verify it with a lightweight JWT check in middleware

Advertisement

Sanjeev Sharma

Written by

Sanjeev Sharma

Full Stack Engineer · E-mopro

Related reading