Next.js Middleware: Auth Guards and Edge Functions (2026)

Next.js Middleware runs at the Edge — before a request reaches a route. It can read and modify request/response headers, rewrite URLs, redirect, and set cookies — all without spinning up a full Node.js server. The key use case is authentication: check a session token centrally in middleware and redirect unauthenticated users to login, rather than checking auth in every route.

Middleware Basics

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

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

export function middleware(request: NextRequest) {
  // Runs before every matched request
  console.log('Path:', request.nextUrl.pathname)

  // Pass through (default behaviour)
  return NextResponse.next()
}

// Optional: limit which paths middleware runs on
export const config = {
  matcher: '/about/:path*',
}
Edge Runtime: Middleware runs in the Edge Runtime — a lightweight V8 environment. It's faster than Node.js but has no access to the filesystem, Node.js APIs, or most npm packages. Use Web APIs only.

Matcher Config

export const config = {
  matcher: [
    // Match all paths except static files and _next
    '/((?!_next/static|_next/image|favicon.ico|public/).*)',

    // Match specific paths
    '/dashboard/:path*',
    '/admin/:path*',
    '/api/:path*',

    // Regex — match all except certain extensions
    '/((?!.*\\.(?:jpg|jpeg|png|gif|svg|ico|css|js)$).*)',
  ],
}

Use the missing option to skip middleware when certain headers are absent:

export const config = {
  matcher: [
    {
      source: '/((?!_next/static|_next/image|favicon.ico).*)',
      missing: [
        { type: 'header', key: 'next-router-prefetch' },
        { type: 'header', key: 'purpose', value: 'prefetch' },
      ],
    },
  ],
}

Auth Guard Pattern

// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import { verifyJWT } from './lib/auth-edge'  // Must be edge-compatible

const PUBLIC_PATHS = ['/', '/login', '/register', '/about', '/api/auth']
const ADMIN_PATHS = ['/admin']

function isPublic(pathname: string) {
  return PUBLIC_PATHS.some(p => pathname === p || pathname.startsWith(p + '/'))
}

function isAdmin(pathname: string) {
  return ADMIN_PATHS.some(p => pathname.startsWith(p))
}

export async function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl

  if (isPublic(pathname)) return NextResponse.next()

  // Read session token
  const token = request.cookies.get('token')?.value
    ?? request.headers.get('authorization')?.replace('Bearer ', '')

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

  try {
    const payload = await verifyJWT(token)

    // Admin route check
    if (isAdmin(pathname) && payload.role !== 'admin') {
      return NextResponse.redirect(new URL('/unauthorized', request.url))
    }

    // Forward user info to route via header
    const response = NextResponse.next()
    response.headers.set('x-user-id', payload.sub)
    response.headers.set('x-user-role', payload.role)
    return response

  } catch {
    const loginUrl = new URL('/login', request.url)
    loginUrl.searchParams.set('callbackUrl', pathname)
    const response = NextResponse.redirect(loginUrl)
    response.cookies.delete('token')  // Clear invalid token
    return response
  }
}

export const config = {
  matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
}

Redirects and Rewrites

export function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl

  // Permanent redirect (308)
  if (pathname.startsWith('/old-blog')) {
    return NextResponse.redirect(
      new URL(pathname.replace('/old-blog', '/blog'), request.url),
      { status: 308 }
    )
  }

  // Rewrite — URL stays the same, content from different path
  if (pathname.startsWith('/products')) {
    const url = request.nextUrl.clone()
    url.pathname = '/shop' + pathname.slice('/products'.length)
    return NextResponse.rewrite(url)
  }

  // Locale redirect
  const locale = request.cookies.get('locale')?.value ?? 'en'
  if (!pathname.startsWith(`/${locale}`)) {
    return NextResponse.redirect(new URL(`/${locale}${pathname}`, request.url))
  }

  return NextResponse.next()
}

Request and Response Headers

export function middleware(request: NextRequest) {
  const response = NextResponse.next({
    request: {
      // Add headers to the request (visible in Server Components via headers())
      headers: new Headers({
        ...Object.fromEntries(request.headers),
        'x-pathname': request.nextUrl.pathname,
        'x-request-id': crypto.randomUUID(),
      }),
    },
  })

  // Add headers to the response (visible to the browser)
  response.headers.set('X-Frame-Options', 'DENY')
  response.headers.set('X-Content-Type-Options', 'nosniff')
  response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin')
  response.headers.set(
    'Content-Security-Policy',
    "default-src 'self'; script-src 'self' 'unsafe-inline'"
  )

  return response
}

Geolocation and Edge Data

import { geolocation, ipAddress } from '@vercel/functions'

export function middleware(request: NextRequest) {
  // Vercel provides geo data automatically
  const { country, city, region } = geolocation(request)
  const ip = ipAddress(request)

  // Redirect users from restricted countries
  if (country === 'XX') {
    return NextResponse.redirect(new URL('/geo-restricted', request.url))
  }

  // Pass geo data to pages
  const response = NextResponse.next()
  response.headers.set('x-user-country', country ?? 'unknown')
  response.headers.set('x-user-city', city ?? 'unknown')
  return response
}

A/B Testing

export function middleware(request: NextRequest) {
  // Get or assign bucket
  let bucket = request.cookies.get('ab-bucket')?.value

  if (!bucket) {
    bucket = Math.random() < 0.5 ? 'a' : 'b'
  }

  // Rewrite to variant page
  const url = request.nextUrl.clone()
  if (request.nextUrl.pathname === '/landing') {
    url.pathname = bucket === 'b' ? '/landing-b' : '/landing'
  }

  const response = NextResponse.rewrite(url)

  // Persist bucket in cookie
  if (!request.cookies.get('ab-bucket')) {
    response.cookies.set('ab-bucket', bucket, {
      maxAge: 60 * 60 * 24 * 30,  // 30 days
      httpOnly: true,
    })
  }

  return response
}

Rate Limiting at the Edge

// Simple in-memory rate limiting (use Upstash Redis for production)
import { Ratelimit } from '@upstash/ratelimit'
import { Redis } from '@upstash/redis'

const ratelimit = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(10, '10 s'),  // 10 requests per 10 seconds
})

export async function middleware(request: NextRequest) {
  if (request.nextUrl.pathname.startsWith('/api/')) {
    const ip = request.headers.get('x-forwarded-for') ?? '127.0.0.1'
    const { success, limit, remaining, reset } = await ratelimit.limit(ip)

    if (!success) {
      return NextResponse.json(
        { error: 'Too Many Requests' },
        {
          status: 429,
          headers: {
            'X-RateLimit-Limit': String(limit),
            'X-RateLimit-Remaining': String(remaining),
            'X-RateLimit-Reset': String(reset),
          },
        }
      )
    }
  }

  return NextResponse.next()
}

Edge Runtime Limitations

Middleware runs in the Edge Runtime — not full Node.js. Constraints to know:

  • No fs, path, child_process or other Node.js built-ins
  • No native npm packages (those using Node.js APIs)
  • JWT verification must use Web Crypto API (jose library works, jsonwebtoken does not)
  • No database connections — use HTTP-based edge-compatible services (Upstash, PlanetScale HTTP driver)
  • Max 1MB bundle size per middleware file
  • Max 25MB response size
// Edge-compatible JWT verification with jose
import { jwtVerify } from 'jose'

const secret = new TextEncoder().encode(process.env.JWT_SECRET)

export async function verifyJWT(token: string) {
  const { payload } = await jwtVerify(token, secret)
  return payload
}