Home JavaScript Next.js 16 Middleware — Missing matcher Caused Auth Check on Every Static File (12x CPU)
Intermediate 4 min · July 12, 2026

Next.js 16 Middleware — Missing matcher Caused Auth Check on Every Static File (12x CPU)

Next.js middleware runs on EVERY request by default.

N
Naren Founder & Principal Engineer

20+ years shipping production JavaScript and front-end systems at scale. Notes here come from systems that actually shipped.

Follow
Production
production tested
July 12, 2026
last updated
1,787
articles · all by Naren
Before you start⏱ 30 min
  • Next.js App Router
  • Understanding of HTTP request/response cycle
  • Node.js 18+
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
Quick Answer
  • Middleware runs on every request by default — including static files (CSS, JS, images, fonts) — an unconfigured matcher causes Edge Runtime to execute on ALL assets
  • The matcher config is not optional — without it, your auth check runs on favicon.ico, _next/static/*, and every API route
  • Middleware blocks the request-response cycle — every millisecond spent in middleware adds to TTFB for every asset
  • Use matcher with regex patterns to restrict middleware to specific paths: matcher: ['/((?!api|_next/static|favicon.ico).*)']
  • Geolocation, rate limiting, A/B testing, and session refresh are valid middleware use cases — but only on the paths that need them
  • Missing matcher on a high-traffic site caused CPU to spike 12x — middleware ran on 95% of requests that were static assets
✦ Definition~90s read
What is Middleware in Next.js 16?

Next.js middleware is a function that runs on the Edge Runtime before every matching request. It can inspect and modify requests, rewrite URLs, redirect users, set headers and cookies, and implement geolocation-based routing. Middleware is defined in a single middleware.ts file at the root of the project.

Next.js middleware is like a security guard checking every single person entering a building — including delivery drivers, mail carriers, and people just walking past.

Middleware sits between the CDN and your application — it acts as a routing layer that can authenticate users, assign A/B test variants, localize content based on geography, and maintain sessions — all before the request reaches your page components.

The critical configuration is the matcher, which controls which request paths trigger middleware execution. Without a matcher, middleware runs on all requests including static assets, which multiplies execution cost and degrades performance.

Plain-English First

Next.js middleware is like a security guard checking every single person entering a building — including delivery drivers, mail carriers, and people just walking past. Most of those people just need to deliver packages (serve static files) and should not be checked. The matcher config tells the guard which doors to stand at — without it, the guard checks everyone, slowing down the entire building.

⚙ Browser compatibility
Latest versions — ✓ supported
ChromeFirefoxSafariEdge

Middleware in Next.js runs on the Edge Runtime before every request reaches your application. The default is 'every path' — including /_next/static/*, /favicon.ico, all CSS/JS chunks, images, and API routes. Without a matcher, your authentication middleware checks the session cookie on every single static asset request.

On a typical page load, the browser requests 30-80 static assets (CSS, JS, images, fonts). Without a matcher, your auth middleware runs 30-80 times per page view — once for the HTML, and 30-80 times for assets that never need authentication. This multiplies Edge function invocations, increases CPU usage, and adds latency to every asset delivery.

This article covers middleware matcher configuration, rewrite/redirect strategies, geolocation-based routing, session refresh patterns, and a production incident where a missing matcher caused 12x CPU usage on a high-traffic e-commerce site.

Tested on Next.js 16.0.0-canary. Middleware behavior is consistent across Next.js 12+.

Matcher Is Not Optional — The Default Will Destroy Your Performance

Next.js middleware runs on EVERY request by default. Not 'every route' — every request. CSS files, JS chunks, images, fonts, favicon.ico, robots.txt, sitemap.xml, API routes. All of them. The default matcher is '/:path*' — match everything.

The performance impact is multiplicative. A typical page load makes 50-100 requests for static assets. Without a matcher, your middleware runs 50-100 times per page view. Each middleware execution blocks the asset delivery — every millisecond in middleware adds 50-100ms to the total page load.

Edge Function pricing on Vercel: $2 per 1M invocations (or included in Pro plan). Without matcher: 12M invocations/day = $24/day in overage. With proper matcher: 1M invocations/day = included in plan. The cost savings alone justify the 30 seconds it takes to add matcher.

The matcher config accepts two formats
  • Array of path patterns: matcher: ['/dashboard/:path', '/account/:path']
  • Single regex string: matcher: '/((?!_next/static|favicon.ico).*)'

Use the array format when you know exactly which paths need middleware. Use the regex format when you want to exclude static assets while running on everything else.

middleware.ts — matcher patternsTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
// ============================================
// Middleware Matcher Configuration
// NEVER deploy middleware without a matcher
// ============================================

// BAD: No matcher — runs on EVERY request
// export { default } from 'next-auth/middleware'

export { default } from 'next-auth/middleware'

// BAD: Too broad matcher — only excludes _next/static
// Still runs on API routes, images, fonts, etc.
export const config = {
  matcher: ['/((?!_next/static).*)']
}

// GOOD: Explicit protected paths can run middleware on routes that need it
export const config = {
  matcher: [
    '/account/:path*',
    '/checkout/:path*',
    '/admin/:path*',
    '/api/orders/:path*',
  ]
}

// BETTER: Exclude all static and public assets
// This runs middleware on all routes EXCEPT listed paths
export const config = {
  matcher: [
    /*
     * Match all request paths except:
     * - _next/static (static files)
     * - _next/image (image optimization files)
     * - favicon.ico (favicon file)
     * - public directory files
     * - api/auth (NextAuth.js API routes — handles its own auth)
     */
    '/((?!api/auth|_next/static|_next/image|favicon.ico|images|fonts).*)',
  ]
}

// BEST: Use explicit path list when possible
// Faster to evaluate and easier to audit
export const config = {
  matcher: [
    '/account/:path*',
    '/checkout/:path*',
    '/admin/:path*',
    '/api/protected/:path*',
  ]
}
Try it live
Always Add Matcher Before First Deploy
Deploying middleware without matcher is a production incident waiting to happen. Add matcher in the SAME PR that creates middleware.ts. Not in a follow-up. The default 'match everything' is a performance trap.
Production Insight
e-commerce site deployed export { default } from 'next-auth/middleware' without matcher. 12M edge invocations/day instead of 1M. 15-minute fix saved $550/month.
Rule: every middleware.ts file must have an accompanying export const config = { matcher: [...] }.
Key Takeaway
Middleware runs on EVERY request by default — static assets, API routes, everything.
Always add matcher config: array format for explicit paths, regex format for broad exclusion.
Missing matcher causes 50-100x more middleware executions than needed per page load.
nextjs-middleware-complete-guide THECODEFORGE.IO Middleware and Routing Layers in Next.js 16 Component hierarchy showing middleware placement and rewrite order Edge Network CDN | Static Assets | Geolocation Data Middleware Layer Matcher Filter | Auth Check | Session Refresh Rewrite Engine BeforeFiles | AfterFiles | Fallback Route Handlers API Routes | Server Actions | Page Components next.config.js Redirects | Rewrites | Headers THECODEFORGE.IO
thecodeforge.io
Nextjs Middleware Complete Guide

Middleware Rewrites: BeforeFiles, AfterFiles, and Fallback Priority

Middleware rewrites are not the only rewrite mechanism in Next.js. next.config.js rewrites have three phases: beforeFiles, afterFiles, and fallback. Middleware rewrites apply between beforeFiles and afterFiles.

The priority chain: 1. next.config.js beforeFiles rewrites 2. Middleware rewrites (from middleware.ts) 3. next.config.js afterFiles rewrites (regular rewrites) 4. Dynamic routes ([param], [...slug]) 5. next.config.js fallback rewrites

This ordering matters when using middleware for A/B testing or geolocation. If your middleware rewrites /products to /products?variant=B, but next.config.js has a beforeFiles rewrite that catches /products first, the middleware rewrite never runs.

For geolocation rewrites, add the rewrite logic in middleware and ensure next.config.js does not have a conflicting beforeFiles rule. Test the rewrite chain by adding console.log in middleware and checking the build output.

middleware.ts — rewrite with geolocationTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
// ============================================
// Middleware Rewrite with Geolocation
// ============================================

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

export function middleware(request: NextRequest) {
  const { nextUrl, geo } = request

  // Skip for excluded paths
  if (
    nextUrl.pathname.startsWith('/_next') ||
    nextUrl.pathname.startsWith('/api') ||
    nextUrl.pathname === '/favicon.ico'
  ) {
    return
  }

  // Geolocation-based rewrite for product pages
  // geo.country is available on Vercel Edge, Cloudflare Workers
  if (nextUrl.pathname.startsWith('/products/') && geo?.country) {
    const country = geo.country.toLowerCase()
    const localeMap: Record<string, string> = {
      us: 'en-US',
      gb: 'en-GB',
      de: 'de-DE',
      fr: 'fr-FR',
      jp: 'ja-JP',
    }

    const locale = localeMap[country] || 'en-US'

    // Rewrite the URL with locale — user sees /products/shoe
    // but the app renders /products/shoe?locale=en-US
    const url = nextUrl.clone()
    url.searchParams.set('locale', locale)

    return NextResponse.rewrite(url)
  }

  // A/B testing variant assignment
  if (nextUrl.pathname === '/pricing') {
    const cookie = request.cookies.get('ab-test-variant')
    let variant = cookie?.value

    if (!variant) {
      variant = Math.random() < 0.5 ? 'A' : 'B'
      const response = NextResponse.next()
      response.cookies.set('ab-test-variant', variant, {
        maxAge: 60 * 60 * 24 * 30, // 30 days
      })
      return response
    }

    if (variant === 'B') {
      const url = nextUrl.clone()
      url.pathname = '/pricing-v2'
      return NextResponse.rewrite(url)
    }
  }
}

export const config = {
  matcher: [
    '/((?!_next/static|_next/image|favicon.ico).*)',
  ]
}
Try it live
Rewrite Priority: beforeFiles > Middleware > afterFiles > Dynamic
Middleware rewrites sit between beforeFiles and afterFiles. If you need middleware rewrites to take priority over dynamic routes, put exclusion rules in matcher. If you need beforeFiles priority, move the rewrite to next.config.js.
Production Insight
A team's geolocation middleware was not working because next.config.js had a beforeFiles rewrite for /products that caught the request before middleware ran.
Rule: check next.config.js rewrites before debugging middleware — they may intercept before middleware executes.
Key Takeaway
Middleware rewrites apply between beforeFiles and afterFiles — not at the top of the chain.
Use matcher to control which paths reach middleware — a broad matcher with internal path checks is inefficient.
Geolocation, A/B testing, and locale detection are valid middleware rewrite use cases.

Middleware Redirects vs next.config.js Redirects — Priority and Use Cases

next.config.js redirects have HIGHER priority than middleware redirects. If both define a redirect for the same path, next.config.js wins. Middleware redirects are evaluated during the request (dynamic), while next.config.js redirects are evaluated at build time (static).

Use next.config.js redirects for
  • Permanent URL changes (301 redirects that should be cached by search engines)
  • Domain migrations
  • Path restructurings that are stable and unchanging
Use middleware redirects for
  • Authentication-based redirects (redirect unauthenticated users to /login)
  • Feature flag redirects (redirect to maintenance page)
  • Temporary redirects that need access to request context (cookies, headers, geolocation) -Dynamic redirects that change based on request-time data

Performance note: next.config.js redirects are evaluated at the CDN/edge and do not incur Edge Function runtime. Middleware redirects run through the Edge Function and cost execution time. Whenever possible, use next.config.js for static redirects.

next.config.ts — static redirects vs middleware redirectsTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
// ============================================
// next.config.js redirects (higher priority)
// ============================================

import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  async redirects() {
    return [
      // Static redirect — evaluated at CDN level, no Edge Runtime
      {
        source: '/old-products',
        destination: '/products',
        permanent: true, // 301 — cached by browsers/SEOs
      },
      {
        source: '/old-blog/:slug',
        destination: '/blog/:slug',
        permanent: true,
      },
    ]
  },
}

export default nextConfig

// ============================================
// middleware.ts redirects (lower priority, dynamic)
// ============================================

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

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

  // Auth redirect — runs on Edge Runtime, checks session
  const sessionToken = request.cookies.get('session-token')
  const isProtectedPath = nextUrl.pathname.startsWith('/account')

  if (isProtectedPath && !sessionToken) {
    const loginUrl = nextUrl.clone()
    loginUrl.pathname = '/login'
    loginUrl.searchParams.set('redirect', nextUrl.pathname)
    return NextResponse.redirect(loginUrl)
  }

  // Maintenance mode redirect — checks feature flag
  if (
    nextUrl.pathname === '/checkout' &&
    process.env.MAINTENANCE_MODE === 'true'
  ) {
    return NextResponse.redirect(new URL('/maintenance', nextUrl))
  }
}

export const config = {
  matcher: ['/account/:path*', '/checkout']
}
Try it live
Prefer next.config.js for Static Redirects
next.config.js redirects execute at the CDN level — no Edge Runtime invocation, no additional cost. Middleware redirects run through Edge Functions. Use next.config.js for any redirect that does not need request-time data (cookies, headers, geolocation).
Production Insight
A team put all redirects in middleware because they thought it was the canonical pattern. They paid Edge Function costs for 50+ redirects that could have been static next.config.js entries. Monthly cost: $200 vs $0.
Rule: if the redirect does not change based on request context, put it in next.config.js.
Key Takeaway
next.config.js redirects have higher priority than middleware redirects.
next.config.js redirects execute at CDN level (no Edge Runtime cost) — use for static redirects.
Middleware redirects use Edge Runtime — use for auth, feature flags, and request-time decisions.
Middleware with vs without Matcher Performance and behavior comparison for auth checks With Matcher Without Matcher Scope of Execution Only protected routes All routes including static files CPU Overhead Normal (baseline) 12x increase on static assets Auth Check Frequency Per request to protected routes Per request to every file Static File Caching Unaffected Bypassed or delayed Configuration Complexity Simple regex patterns None required but dangerous Recommended Use Always define matcher Never omit matcher THECODEFORGE.IO
thecodeforge.io
Nextjs Middleware Complete Guide

Geolocation in Middleware: Country-Based Routing Without a Third-Party API

Next.js middleware provides geolocation data through the request.geo object. On Vercel, this includes country, city, region, latitude, and longitude — populated from the CDN edge node's IP geolocation database. No third-party API call needed, no additional latency.

The geo data is available in the middleware's Edge Runtime but NOT in Server Components or Route Handlers by default. If you need geo data in pages, pass it through headers or cookies in middleware.

Pattern: check geo.country in middleware, set a cookie with the locale, rewrite the URL with locale as a search param. Server Components read the cookie or search params to localize content.

Important: geolocation data is only available on platforms that provide it (Vercel, Cloudflare via request.cf). Self-hosted Next.js on plain Node.js does not populate geo data — you need to add your own IP geolocation or skip geo-dependent middleware for self-hosted deployments.

middleware.ts — geolocation to header patternTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
// ============================================
// Geolocation to Header Pattern
// ============================================

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

export function middleware(request: NextRequest) {
  const { geo, nextUrl } = request

  // Skip non-page requests
  if (
    nextUrl.pathname.startsWith('/_next') ||
    nextUrl.pathname.startsWith('/api')
  ) {
    return NextResponse.next()
  }

  // Vercel/Cloudflare provides geo data
  // Self-hosted: returns undefined
  if (geo?.country) {
    const response = NextResponse.next()

    // Set header for Server Components to read
    response.headers.set('x-geo-country', geo.country)
    response.headers.set('x-geo-city', geo.city || '')
    response.headers.set('x-geo-region', geo.region || '')

    // Set cookie for client-side access
    response.cookies.set('user-country', geo.country, {
      maxAge: 60 * 60 * 24, // 24 hours
      httpOnly: false, // Allow JS access for analytics
      sameSite: 'lax',
    })

    return response
  }

  return NextResponse.next()
}

// app/page.tsx — read geolocation from header
import { headers } from 'next/headers'

export default async function HomePage() {
  const headersList = await headers()
  const country = headersList.get('x-geo-country') || 'US'

  return (
    <div>
      <p>Your country: {country}</p>
      {/* Country-specific content */}
    </div>
  )
}

export const config = {
  matcher: [
    '/((?!_next/static|_next/image|favicon.ico).*)',
  ]
}
Try it live
Geo Data: Edge-Only, Not Server Component Native
Geo data is only available in middleware (Edge Runtime). To use it in Server Components, forward it via headers or cookies in middleware. Without this explicit forwarding, geo data is unavailable in page components.
Production Insight
A team deployed geo-personalized pricing using middleware headers. It worked on Vercel but broke on self-hosted — geo was always undefined because self-hosted Node.js does not provide IP geolocation.
Rule: always provide a fallback for geo data when deploying to non-Vercel environments.
Key Takeaway
Geolocation data is available in middleware via request.geo on Vercel/Cloudflare.
Forward geo data to Server Components via headers set in middleware.
Self-hosted environments do not provide geo data — always provide fallback values.

Session Refresh in Middleware: The Silent Performance Killer

Refreshing authentication sessions in middleware is a common pattern — check the session expiry, refresh via a token API, update the cookie. The problem: this happens on EVERY matching middleware invocation. If the session refresh involves a database lookup or an external API call, each middleware execution blocks for the duration of that call.

A session refresh that takes 200ms (db lookup + token rotation) on a matcher that runs on 50 paths per page view adds 10 seconds of blocking time to each page load. Worse: the Edge Runtime has limited connectivity — outbound HTTP requests from Edge Functions have higher latency and stricter timeouts than serverless functions.

The fix: defer session refresh to Route Handlers. Only check session presence in middleware (fast, <1ms cookie read). If the session needs refreshing, redirect to a Route Handler that performs the refresh and redirects back. This moves the 200ms operation out of the critical path.

Or use a short-lived access token with a long-lived refresh token pattern. The access token is validated in middleware (JWT decode, no DB call) and the refresh is done asynchronously via a Route Handler when the access token expires.

middleware.ts — session presence check, not refreshTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
// ============================================
// BAD: Session refresh in middleware
// ============================================

// DON'T DO THIS — blocks every matching request
export async function middleware(request: NextRequest) {
  const token = request.cookies.get('session')

  if (token) {
    // This external call blocks every request for 200ms
    const refreshed = await fetch('https://auth.example.com/refresh', {
      method: 'POST',
      headers: { 'Authorization': `Bearer ${token.value}` }
    })

    if (refreshed.ok) {
      const { newToken } = await refreshed.json()
      const response = NextResponse.next()
      response.cookies.set('session', newToken, { maxAge: 3600 })
      return response
    }
  }

  return NextResponse.next()
}

// ============================================
// GOOD: Session check only — refresh deferred
// ============================================

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

  // Only check protected paths
  const protectedPaths = ['/account', '/checkout', '/orders']
  const isProtected = protectedPaths.some(
    path => nextUrl.pathname.startsWith(path)
  )

  if (!isProtected) return NextResponse.next()

  const token = request.cookies.get('session')

  if (!token) {
    // No token at all — redirect to login
    const loginUrl = nextUrl.clone()
    loginUrl.pathname = '/login'
    loginUrl.searchParams.set('redirect', nextUrl.pathname)
    return NextResponse.redirect(loginUrl)
  }

  // Token exists — let the page handle validation/refresh
  // This is fast (<1ms) because we only read a cookie
  return NextResponse.next()
}

// app/account/page.tsx — handles actual session validation
import { refreshSession } from '@/lib/auth'

export default async function AccountPage() {
  const session = await refreshSession()
  // This runs in Server Components — no Edge Runtime timeout issues
  
  if (!session.isValid) {
    // Handle expired/invalid session
    return <RedirectToLogin />
  }

  return <AccountContent session={session} />
}
Try it live
Never Do Database Calls in Middleware
Middleware runs on Edge Runtime with limited Node.js APIs. Database connections, filesystem access, and long external API calls are not available or perform poorly. Keep middleware to cookie/header inspection only — less than 5ms execution time.
Production Insight
A team's session refresh in middleware caused 5-second TTFB on protected pages. The refresh API call + DB update took 400ms, and on slow cellular connections it timed out entirely.
Rule: middleware should only check session PRESENCE (cookie exists), not session VALIDITY (token is fresh). Defer validity checks to Route Handlers or Server Components.
Key Takeaway
Keep middleware execution under 5ms — cookie/header reads only.
Defer session refresh to Route Handlers — middleware is not the place for external API calls.
Check session presence in middleware; validate and refresh in Server Components or Route Handlers.

Middleware and next.config.js Rewrite Order: The Full Chain

  1. next.config.js — headers (set response headers before anything)
  2. next.config.js — redirects (highest priority, evaluated by CDN)
  3. next.config.js — rewrites (beforeFiles)
  4. Middleware — runs on the request (can rewrite, redirect, set headers, set cookies)
  5. next.config.js — rewrites (afterFiles)
  6. File-system routing — matches page.tsx, layout.tsx, route.ts
  7. next.config.js — rewrites (fallback)

Middlewares runs AFTER beforeFiles rewrites but BEFORE afterFiles rewrites. This means: - beforeFiles rewrites can intercept requests before middleware sees them - Middleware can rewrite URLs that then match afterFiles rewrites - fallback rewrites catch URLs that no route matched

Practical implications
  • If you need middleware to run on a rewritten URL, the middleware matcher must match the FINAL URL, not the original
  • If middleware rewrites a URL, the new URL goes through afterFiles rewrites but NOT beforeFiles rewrites again (no loop)
  • Static redirects in next.config.js (301) catch requests before middleware — middleware cannot override them
Debugging rewrite chainTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
// ============================================
// Testing the rewrite chain
// ============================================

// next.config.ts
// Add logging to understand the rewrite chain

const nextConfig: NextConfig = {
  async rewrites() {
    return {
      beforeFiles: [
        // Stage 1: beforeFiles — runs before middleware
        {
          source: '/admin-old',
          destination: '/admin',
        },
      ],
      afterFiles: [
        // Stage 3: afterFiles — runs after middleware
        {
          source: '/products/:path*',
          destination: '/catalog/:path*',
        },
      ],
      fallback: [
        // Stage 5: fallback — no route matched
        {
          source: '/:path*',
          destination: '/not-found',
        },
      ],
    }
  },
}

// middleware.ts — runs between beforeFiles and afterFiles
// If middleware rewrites /products to /catalog:
// - The rewrite happens after any beforeFiles rewrite
// - The new URL (/catalog) goes through afterFiles rewrites
// - afterFiles can further rewrite /catalog to something else

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

  // This runs after beforeFiles, before afterFiles
  if (nextUrl.pathname.startsWith('/admin')) {
    // Check admin access — redirect to login if not authorized
    const isAdmin = request.cookies.get('admin-token')
    if (!isAdmin) {
      return NextResponse.redirect(new URL('/login', nextUrl))
    }
  }

  return NextResponse.next()
}
Try it live
Rewrite Chain Order Matters
beforeFiles > Middleware > afterFiles > File System > fallback. If a rewrite is not working, check which stage it belongs to. Middleware rewrites cannot override beforeFiles, and afterFiles rewrites cannot undo middleware redirects.
Production Insight
A team's middleware rewrite to /products-v2 was being undone by an afterFiles rewrite back to /products. The two rewrites created an infinite redirect loop that crashed the edge function.
Rule: when combining middleware and next.config.js rewrites, trace the full chain for loops. A URL can be rewritten multiple times — each rewrite adds latency.
Key Takeaway
Rewrite chain: beforeFiles > Middleware > afterFiles > File System > fallback.
Middleware rewrites apply AFTER beforeFiles but BEFORE afterFiles — use this to layer transformations.
Test rewrite chains in development with 'next dev' — an infinite redirect loop crashes the dev server.

Middleware Limitations: What You Cannot Do in Edge Runtime

Middleware runs on Edge Runtime, which is a subset of standard Node.js APIs. Key limitations:

  • No database connections (no Prisma, no pg, no MongoDB driver — these use Node.js TCP/Unix sockets not available in Edge Runtime)
  • No filesystem access (no fs.readFile, no readdir)
  • No Node.js built-in modules (crypto, path, os, stream are not available — use Web Crypto API and URL APIs instead)
  • No long-running operations (Edge Functions have 25s timeout on Vercel, but middleware is expected to complete in under 10ms)
  • No async local storage (no AsyncLocalStorage)
  • Limited Buffer support (use Uint8Array instead)
  • No import of server-only modules (any module that uses Node.js internals will fail)
Workarounds
  • Use Web APIs (fetch, Request, Response, URL, URLSearchParams, crypto.subtle)
  • Defer heavy operations to Route Handlers or Server Components
  • Use edge-compatible JWT libraries (jose instead of jsonwebtoken)
  • Keep middleware thin — it is a routing layer, not an application layer
Edge-compatible vs Node.js APIs in middlewareTYPESCRIPT
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
// ============================================
// Edge Runtime API Compatibility
// ============================================

// ❌ NOT AVAILABLE in Edge Runtime:
// import { PrismaClient } from '@prisma/client' // TCP/Unix sockets
// import { sign, verify } from 'jsonwebtoken' // Node.js crypto
// import { readFile } from 'fs/promises' // Filesystem
// import crypto from 'crypto' // Node.js crypto module
// import path from 'path' // Node.js path module

// ✅ AVAILABLE in Edge Runtime:
import { jwtVerify } from 'jose' // Web Crypto API compatible
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

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

  if (token) {
    try {
      // jose uses Web Crypto API — works on Edge
      const { payload } = await jwtVerify(
        token,
        new TextEncoder().encode(process.env.JWT_SECRET!)
      )

      // Forward user info via header
      const response = NextResponse.next()
      response.headers.set('x-user-id', payload.sub as string)
      return response
    } catch {
      // Token invalid — continue without user context
      // Actual redirect handled by page component
      return NextResponse.next()
    }
  }

  return NextResponse.next()
}

export const config = {
  matcher: ['/account/:path*', '/checkout/:path*']
}
Try it live
Node.js Modules Do Not Work in Middleware
If you import a library in middleware.ts, verify it is Edge-compatible. Prisma, Mongoose, jsonwebtoken, bcrypt, and most ORMs do not work on Edge Runtime. Use Edge-compatible alternatives or move the logic to Route Handlers.
Production Insight
A team's middleware crashed on every request because they imported Prisma for a database session lookup. The error was a silent 500 on every page — no obvious stack trace because Edge Runtime errors are hard to catch.
Rule: if middleware fails silently, check for incompatible Node.js imports. Strip down to pure Web APIs first, then incrementally add complexity.
Key Takeaway
Middleware uses Edge Runtime — limited to Web APIs (fetch, crypto.subtle, URL).
No database connections, filesystem access, or Node.js modules.
Use edge-compatible libraries (jose for JWT, nanoid for IDs) or defer operations to Route Handlers.
● Production incidentPOST-MORTEMseverity: high

Auth Middleware Without Matcher — 12x Edge Function Cost, Pages Slowing Down

Symptom
Middleware was added in middleware.ts without a matcher config. The default behavior runs middleware on every request — including /_next/static/*.js, .css, .png, .webp, /favicon.ico, and all other static assets. The middleware's session cookie check and database lookup ran 80+ times per page load, blocking every static file delivery.
Root cause
The developer who added middleware.ts did not include a matcher configuration. Next.js middleware documentation shows examples WITHOUT matcher in quick-start sections, leading developers to believe it is optional. It is not optional for production — without matcher, middleware runs on every edge request including all static assets.
Fix
Added matcher to middleware.ts: export const config = { matcher: ['/account/:path', '/checkout/:path', '/api/auth/:path*'] }. Edge function invocations dropped from 12M/day to 800K/day — a 93% reduction. Page load time returned to 800ms. LCP returned to 1.8s. Cloud bill dropped from $600/month to $45/month.
Key lesson
  • Matcher is not optional — always specify which paths middleware should run on
  • Default behavior runs middleware on ALL requests including static assets — this is almost never what you want
  • Before matcher fix: 80 middleware calls per page view. After: 2-3 calls per page view
  • Always check Cloudflare/Edge function metrics after deploying middleware — a sudden spike indicates missing matcher
  • Document matcher patterns in your team's Next.js conventions — this mistake is too easy to make
Production debug guideDiagnose slow middleware, over-invocation, and configuration issues4 entries
Symptom · 01
Edge function invocations spiked after adding middleware
Fix
Check if matcher config is missing or too broad. Middleware without matcher runs on every request. Add matcher to restrict paths. Also check for too many middleware files — only one middleware.ts is supported per project.
Symptom · 02
Authentication middleware runs on /_next/static files
Fix
Matcher regex needs to exclude static paths. Use matcher: ['/((?!api|_next/static|favicon.ico).*)'] to exclude. Or explicitly list protected paths instead of using a negative lookahead.
Symptom · 03
Middleware rewrite/redirect not working as expected
Fix
Middleware redirects have lower priority than next.config.js redirects. Check if next.config.js redirects or rewrites are intercepting before middleware runs. Also check that the middleware is running on the correct path — matcher may exclude the target URL.
Symptom · 04
Session refresh in middleware causes slow responses
Fix
Middleware runs on Edge Runtime with limited Node.js APIs. Avoid heavy computation, database queries, or external API calls in middleware. Use simple cookie/token validation and defer heavy operations to Route Handlers.
★ Middleware Quick Debug ReferenceFast commands and patterns for middleware debugging
Middleware running on too many paths
Immediate action
Add matcher config to restrict execution paths
Commands
grep 'matcher' middleware.ts || echo 'NO MATCHER FOUND'
Check Vercel/Cloudflare function invocations dashboard
Fix now
export const config = { matcher: ['/protected/:path*'] }
Static assets going through middleware+
Immediate action
Exclude static paths in matcher
Commands
grep -E 'matcher|_next/static|favicon' middleware.ts
Add /_next/static and favicon.ico to exclusion list
Fix now
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)']
Rewrite not applying to certain paths+
Immediate action
Check next.config.js rewrite priority
Commands
grep 'rewrites' next.config.ts
grep 'beforeFiles' next.config.ts
Fix now
Use 'beforeFiles: []' in next.config.js rewrites for higher priority
⚙ Quick Reference
7 commands from this guide
FileCommand / CodePurpose
middleware.ts — matcher patternsexport { default } from 'next-auth/middleware'Matcher Is Not Optional
middleware.ts — rewrite with geolocationexport function middleware(request: NextRequest) {Middleware Rewrites
next.config.ts — static redirects vs middleware redirectsconst nextConfig: NextConfig = {Middleware Redirects vs next.config.js Redirects
middleware.ts — geolocation to header patternexport function middleware(request: NextRequest) {Geolocation in Middleware
middleware.ts — session presence check, not refreshexport async function middleware(request: NextRequest) {Session Refresh in Middleware
Debugging rewrite chainconst nextConfig: NextConfig = {Middleware and next.config.js Rewrite Order
Edge-compatible vs Node.js APIs in middlewareexport async function middleware(request: NextRequest) {Middleware Limitations

Key takeaways

1
Middleware runs on EVERY request by default
always add an explicit matcher config to restrict paths
2
Missing matcher causes 50-100x more middleware executions per page load
12M vs 800K invocations/day
3
Static redirects belong in next.config.js
middleware redirects are for auth, geolocation, and dynamic decisions
4
Middleware uses Edge Runtime
no database, no filesystem, no Node.js modules
5
Keep middleware under 5ms
cookie/header reads only, defer session refresh to Route Handlers
6
Geolocation is middleware-only
forward to Server Components via headers or cookies
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
What is the default scope of Next.js middleware and why is it dangerous?
Q02SENIOR
Explain the rewrite/redirect priority chain in Next.js. Where does middl...
Q03SENIOR
What API limitations does the Edge Runtime have in middleware?
Q04SENIOR
How do you handle geolocation-based routing in Next.js middleware?
Q01 of 04SENIOR

What is the default scope of Next.js middleware and why is it dangerous?

ANSWER
Next.js middleware runs on EVERY request by default — 'matches all paths'. This includes static assets like CSS files, JavaScript bundles, images, fonts, and favicon.ico. The danger is that without an explicit matcher configuration, your middleware (which may contain auth checks, geolocation lookups, or session validation) executes 50-100 times per page view instead of 1-2 times. This manifests as: 12x higher Edge Function invocations, 40% slower page loads (because every static file is blocked by middleware execution), and significantly higher cloud bills ($50/month becoming $600/month in one documented production incident). The fix is always adding a matcher config that either explicitly lists protected paths or uses a regex to exclude static assets. The matcher is not optional — it is a performance-critical configuration that should be added in the same commit as the middleware itself. For production applications, the recommended approach is explicit path lists: matcher: ['/dashboard/:path', '/account/:path']. For broad exclusion: matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'].
FAQ · 7 QUESTIONS

Frequently Asked Questions

01
What happens if I deploy middleware without a matcher?
02
Can I have multiple middleware files in Next.js?
03
What is the difference between next.config.js rewrites and middleware rewrites?
04
How do I debug middleware not running on a specific path?
05
Can middleware access the database?
06
Is middleware executed on every navigation in a client-side app?
07
How do I use environment variables in middleware?
N
Naren Founder & Principal Engineer

20+ years shipping production JavaScript and front-end systems at scale. Notes here come from systems that actually shipped.

Follow
Verified
production tested
July 12, 2026
last updated
1,787
articles · all by Naren
🔥

That's Next.js. Mark it forged?

4 min read · try the examples if you haven't

Previous
Parallel Routes and Intercepting Routes in Next.js 16
32 / 56 · Next.js
Next
Route Handlers: Building API Endpoints in Next.js 16