Skip to content

Next Middleware

Middleware runs at the edge before every matching request. It's your security perimeter , your redirect engine , your A/B testing platform , and your rate limiter - all running in a single file that fires on every request

One middleware.ts file at the root of your project intercepts every request before it reaches your routes. Write it wrong and you block static assets. Write it right and you have a single source of truth for auth , redirects , headers , and geolocation. No other file matters as much for your app's security posture

middleware.ts basics

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

export function middleware(request: NextRequest) {
  // Runs on every matching request before the route handler

  // Log the request
  console.log(`${request.method} ${request.url}`)

  // Continue to the route handler
  return NextResponse.next()
}

export const config = {
  matcher: '/:path*'
}

The matcher config controls which routes trigger the middleware. Without it , middleware runs on every request including static files

matcher config

The matcher is the most important part of your middleware. Get it wrong and:

  • Too broad: middleware runs on every image , font , and JS file (wasted CPU)
  • Too narrow: routes you meant to protect are accessible without auth
export const config = {
  matcher: [
    // Match all routes EXCEPT static files and Next.js internals
    '/((?!_next/static|_next/image|favicon.ico|.*\\.png$).*)',
  ]
}
// Match specific paths only
export const config = {
  matcher: [
    '/dashboard/:path*',   // /dashboard , /dashboard/settings , etc
    '/admin/:path*',       // /admin , /admin/users , etc
    '/api/:path*',         // /api , /api/users , etc
  ]
}
// Match with negative lookahead
export const config = {
  matcher: [
    /*
     * Match all request paths except:
     * - _next/static (static files)
     * - _next/image (image optimization files)
     * - favicon.ico (favicon file)
     * - public folder files (images , fonts)
     */
    '/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
  ]
}

The matcher uses regex paths. Test your patterns. A wrong matcher means auth bypass

auth middleware

The primary use case: protecting routes behind authentication

// src/middleware.ts
import { auth } from '@/lib/auth'
import { NextResponse } from 'next/server'

export default auth((req) => {
  const { pathname } = req.nextUrl
  const isLoggedIn = !!req.auth

  // Define public routes that don't need auth
  const publicRoutes = ['/', '/login', '/register', '/api/auth']
  const isPublicRoute = publicRoutes.some(route =>
    pathname === route || pathname.startsWith(route + '/')
  )

  // Allow public routes
  if (isPublicRoute) {
    return NextResponse.next()
  }

  // Redirect to login if not authenticated
  if (!isLoggedIn) {
    const loginUrl = new URL('/login', req.url)
    loginUrl.searchParams.set('callbackUrl', pathname)
    return NextResponse.redirect(loginUrl)
  }

  // Check role-based access for admin routes
  if (pathname.startsWith('/admin')) {
    const role = req.auth?.user?.role
    if (role !== 'admin') {
      return NextResponse.redirect(new URL('/unauthorized', req.url))
    }
  }

  return NextResponse.next()
})

export const config = {
  matcher: '/((?!_next/static|_next/image|favicon.ico|.*\\.png$).*)'
}

redirect logic

Middleware is the best place for redirects because they happen before any code loads

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

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

  // Redirect old URLs to new ones
  if (pathname.startsWith('/old-blog')) {
    const newPath = pathname.replace('/old-blog', '/blog')
    return NextResponse.redirect(new URL(newPath, request.url))
  }

  // Remove trailing slash (SEO best practice)
  if (pathname !== '/' && pathname.endsWith('/')) {
    const newPath = pathname.slice(0, -1)
    return NextResponse.redirect(new URL(newPath, request.url))
  }

  // Enforce lowercase URLs
  const lowerPath = pathname.toLowerCase()
  if (pathname !== lowerPath) {
    return NextResponse.redirect(new URL(lowerPath, request.url))
  }

  return NextResponse.next()
}

geolocation-based routing

On Vercel , middleware has access to geolocation data

// src/middleware.ts - Vercel edge only
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

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

  // Redirect based on country
  if (country === 'DE' && !request.nextUrl.pathname.startsWith('/de')) {
    return NextResponse.redirect(new URL('/de' + request.nextUrl.pathname, request.url))
  }

  // Block traffic from sanctioned countries
  const blockedCountries = ['IR', 'KP', 'CU', 'SY']
  if (blockedCountries.includes(country || '')) {
    return new Response('Access denied', { status: 403 })
  }

  // Add country header for downstream use
  const response = NextResponse.next()
  response.headers.set('x-geo-country', country || '')
  return response
}

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

Geolocation data is available on Vercel Edge. Self-hosted deployments need to configure their own geo-IP solution

security headers in middleware

The cleanest place to set security headers

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

export function middleware(request: NextRequest) {
  const response = NextResponse.next()

  // Security headers
  const headers = {
    'X-Frame-Options': 'DENY',
    'X-Content-Type-Options': 'nosniff',
    'Referrer-Policy': 'strict-origin-when-cross-origin',
    'X-XSS-Protection': '0', // deprecated but harmless
    'Permissions-Policy': 'camera=(), microphone=(), geolocation=()',
    'Strict-Transport-Security': 'max-age=31536000; includeSubDomains; preload'
  }

  Object.entries(headers).forEach(([key, value]) => {
    response.headers.set(key, value)
  })

  return response
}

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

Headers set in middleware apply to every matched response. This is better than setting them in individual route handlers or next.config.ts because middleware covers redirects and error responses too

middleware limitations

Middleware runs on the Edge runtime. It has constraints:

  • No database access - can't connect to Prisma or any database with native drivers
  • No filesystem - can't read files with fs
  • No long-running operations - 30-second timeout on Vercel Edge
  • Limited API - only Web APIs available (fetch , URL , Request , Response)
  • No req.body parsing - middleware can't read the request body (it would consume the stream)

If you need database access in middleware , you're building a route handler , not middleware

prerequisites

next_10_security.md - you should understand CSRF , XSS , and CSP concepts


next → next_12_env_config.md