Switch Language
Toggle Theme

Next.js Middleware Practical Guide: Path Matching, Edge Runtime Limitations & Common Pitfalls

It was 2 AM, and I was staring at Vercel’s error logs in a daze.

The admin system that just went live—all /dashboard routes were correctly protected during local testing—unauthenticated users would be redirected to the login page. But now in production, users directly accessing /dashboard/settings/profile somehow bypassed verification and saw sensitive data.

I immediately opened the code—the Middleware file was sitting there fine, logic looked correct. What could it be?

After half an hour of digging through Next.js docs, I finally found the answer in the matcher configuration section: I wrote /dashboard/:path, but it only matches one-level paths like /dashboard/settings—multi-level paths fail. The correct way is /dashboard/:path*. That tiny asterisk almost cost me my job.

To be honest, this wasn’t my first stumble with Middleware. Edge Runtime not supporting a library, matcher config not working, infinite redirect loops… I’ve stepped into almost all of these pitfalls.

If you’re also using Next.js Middleware, or planning to use it for authentication, internationalization, A/B testing, this article might help. I’ll break down those easy-to-fall-into pitfalls, hard-to-understand configuration rules, and three complete practical cases.

No fluff like “in today’s web development.” Just real problems—how to write, how to avoid pitfalls, how to make Middleware actually work.

What Is Middleware? Why Use It?

Simply put, Middleware is a “checkpoint.”

Before user requests reach your page or API, they first pass through this checkpoint. You can check user identity, modify request content, or even directly return responses—like kicking unauthenticated users to the login page, or redirecting to different language versions based on user region.

It runs on Edge Runtime—this is crucial. Edge Runtime doesn’t run on your server, but is deployed on edge nodes (CDN) close to users. Close distance, low latency, near-zero cold start. You can think of it as: Middleware is the code layer closest to users.

What’s the Difference Between Edge Runtime and Node.js Runtime?

FeatureEdge RuntimeNode.js Runtime
Startup SpeedNear-zero cold startNeeds hundreds of milliseconds
LocationGlobal edge nodesSpecific server
API SupportWeb standard APIsFull Node.js APIs
Use CasesLightweight logic, fast responseComplex computation, database operations

Simply put: fast, but limited. You can’t use Node.js modules like fs, path, and can’t connect to most databases. This is also where you’ll most easily step into pitfalls.

When Should You Use Middleware?

Not all logic is suitable for Middleware. I’ve summarized the most common use cases:

1. Authentication (Auth Gate)
The classic usage. Check if user is logged in, redirect to login if not. Faster than checking in server components because the request hasn’t even reached the server.

2. Internationalization (i18n)
Based on user language preference (from URL, Cookie, or browser settings), automatically redirect to corresponding language version. //zh or /en.

3. A/B Testing
Randomly divide users into two groups, show different page versions. Use Cookie to keep grouping consistent, avoid users seeing different content after refresh.

4. Bot Detection & Rate Limiting
Intercept crawlers or malicious requests, or rate limit certain IPs.

5. Logging & Analytics
Record basic info for each request (path, source, UA), send to analytics service.

6. Content Rewriting
Map user-accessed URL internally to another path, but browser address bar stays the same. Very useful for dynamic routing or A/B testing.

Why Not Do These Directly in Page Components?

You can, but it’ll be slow. Server component or client component logic executes after requests reach the server, even after rendering. Middleware intercepts at the edge—faster response, better experience.

Another point: centralized management. You don’t want to write authentication logic in every protected page, right? Middleware lets you handle it in one place.

But don’t stuff everything into Middleware. Complex business logic, database queries, heavy computation—leave these to API routes or server components. Middleware should stay lightweight and fast.

Middleware Basic Configuration & File Structure

Where to Put the File?

Next.js has strict requirements for Middleware location: must be in project root or src directory, filename must be middleware.ts (or .js).

Project Root/
├── app/
├── middleware.ts    ← Put here
├── package.json

Or if you use src directory:

Project Root/
├── src/
│   ├── app/
│   ├── middleware.ts    ← Put here
├── package.json

Note: A project can only have one middleware.ts file. Can’t create multiple middleware files in app directory or elsewhere. This design makes sense because Middleware should be the global “gatekeeper.”

What Does the Simplest Middleware Look Like?

import { NextRequest, NextResponse } from 'next/server';

export function middleware(request: NextRequest) {
  console.log('Request came in:', request.url);
  return NextResponse.next(); // Allow, continue execution
}

That’s it. NextResponse.next() means “no problem, continue”—the request will normally reach the target page or API.

Core APIs: NextRequest and NextResponse

NextRequest extends the standard Web Request, providing convenient properties:

  • request.nextUrl: Parsed URL object, can directly get pathname, search, etc.
  • request.cookies: Easier cookie read/write
  • request.geo: User geographic location info (requires platform support, like Vercel)

NextResponse provides several different return methods:

1. Allow (Continue Execution)

return NextResponse.next();

2. Redirect (Jump to New URL)

return NextResponse.redirect(new URL('/login', request.url));

User will see address bar change.

3. Rewrite (Internal Redirect, URL Unchanged)

return NextResponse.rewrite(new URL('/dashboard/v2', request.url));

User accesses /dashboard, actually returns /dashboard/v2 content, but address bar still shows /dashboard. Very useful for A/B testing or version switching.

4. Direct Response

return new NextResponse('Access Denied', { status: 403 });

Don’t continue processing, directly return content to user.

A Slightly More Practical Example: Add Custom Header

import { NextRequest, NextResponse } from 'next/server';

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

  // Add a custom header to all responses
  response.headers.set('x-custom-header', 'my-value');

  return response;
}

This is quite common—like adding a timestamp to all responses, or marking request source.

Version Note (Important!)

If you’re using Next.js 15, note that the official rename of middleware.ts to proxy.ts. Although middleware.ts still works (backward compatible), new projects should use the new name. This article’s code is based on Next.js 14/15, both versions apply, main concepts and APIs haven’t changed.

Path Matching (Matcher): Where You’ll Most Easily Step Into Pitfalls

Honestly, I’ve stepped into the most pitfalls with matcher.

Why Do You Need Matcher?

If you don’t configure matcher, your Middleware will execute for every request—including CSS, JS, images, fonts, these static resources. Imagine a user loading a page, browser requests 20 static files, your Middleware executes 20 times. Not only wastes resources, but may also slow down response.

Matcher’s role is to tell Next.js: “Only execute Middleware on these paths, ignore the rest.”

Basic Syntax

Export a config object in the middleware.ts file:

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

This means: Middleware only executes when accessing paths starting with /dashboard and /api.

Modifiers: What Do *, +, ? Mean?

These symbols control path matching “greediness”:

* (Zero or More)
/dashboard/:path* will match:

  • /dashboard
  • /dashboard/settings
  • /dashboard/settings/profile

+ (One or More)
/dashboard/:path+ will match:

  • /dashboard
  • /dashboard/settings
  • /dashboard/settings/profile

? (Zero or One)
/dashboard/:path? will match:

  • /dashboard
  • /dashboard/settings
  • /dashboard/settings/profile

Most of the time, * is enough.

Common Pitfalls & Solutions (Important!)

This table cost me blood and tears, suggest bookmarking:

ProblemWrong ApproachCorrect ApproachReason
Multi-level dynamic routes don’t match/dashboard/:path/dashboard/:path*Without * only matches one level, multi-level paths fail
Root path missedmatcher: ['/dashboard/:path*']matcher: ['/', '/dashboard/:path*']Root path / not automatically included, need explicit add
Static resources interceptedmatcher: ['/:path*']matcher: ['/((?!_next|favicon.ico).*)']Need regex to exclude _next and other internal paths
API routes not protectedmatcher: ['/api/users']matcher: ['/api/:path*']Specific path only matches that one, use wildcard to cover all APIs

Most Common: Static Resource Trap

You write a matcher like this:

export const config = {
  matcher: ['/:path*'] // Want to match all paths
}

Result: Next.js internal _next/static paths also get matched, Middleware executes like crazy, page loads super slow.

Correct Approach: Use Negative Lookahead to Exclude

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

This regex means: “Match all paths except api, _next/static, _next/image, and favicon.ico.”

Honestly, this regex is quite convoluted—I copied it from the official example. Just copy and use it, no need to dig into the principle.

Another Pitfall: Can’t Use Dynamic Values

const lang = 'zh'; // This is a variable
export const config = {
  matcher: [`/${lang}/:path*`] // ❌ Won't work!
}

Matcher must be static, values determinable at compile time. You can’t use template strings to insert variables, or dynamically generate at runtime.

If you need dynamic judgment, put the logic in the middleware function:

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

  // Dynamic judgment here
  if (pathname.startsWith('/zh') || pathname.startsWith('/en')) {
    // Handle logic
  }

  return NextResponse.next();
}

// Matcher stays static
export const config = {
  matcher: ['/:locale/:path*']
}

Protect Specific Routes (e.g., Admin):

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

Match All Routes But Exclude Static Resources:

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

Protect All API Routes:

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

Not sure if you noticed, Next.js docs on matcher are quite brief, details all come from stepping into pitfalls yourself. Hope this table helps you avoid detours.

Edge Runtime Limitations & Workarounds

The problems in this chapter really confused me the first time I encountered them.

I wanted to verify user identity in Middleware, planned to query database to confirm if token is valid. Code written, ran locally—error: Native Node.js APIs are not supported in the Edge Runtime.

What does that mean? I’m just connecting to a database, why isn’t it supported?

Later I understood: Edge Runtime isn’t a full Node.js environment, many common APIs and libraries can’t be used.

What Doesn’t Edge Runtime Support?

Feature CategoryUnsupported APIs/ModulesImpact
File Systemfs, pathCan’t read/write local files
Child Processchild_processCan’t execute external commands
CryptoSome crypto APIsNeed to use Web Crypto API instead
DatabaseMongoDB, MySQL native driversMost traditional database drivers unavailable
Othersprocess.emit, setImmediateSome low-level Node.js APIs

What’s the Actual Impact?

Most direct impact:

  1. Can’t directly connect to database to verify user identity
  2. Can’t read config files, like reading settings from config.json
  3. Can’t use third-party libraries that depend on Node.js APIs

This sounds very limiting, but there are ways around it.

Workaround: Treat Edge Runtime as “Outpost”

Key idea: Middleware only does lightweight checks, complex logic goes to later stages.

Requirement❌ Edge Runtime Limitation✅ Solution
Verify User IdentityCan’t query databaseUse JWT local verification, or call API route
Encrypt/DecryptSome crypto unavailableUse Web Crypto API
Read ConfigCan’t access file systemUse environment variables (process.env) or API
LoggingCan’t write to local filesSend to third-party logging service (like Logtail)
Database OperationsTraditional drivers unsupportedUse Edge-compatible databases (Vercel Postgres, Supabase)

JWT (JSON Web Token) is the most suitable authentication method for Middleware because it’s stateless—the token itself contains all information, no need to query database.

import { NextRequest, NextResponse } from 'next/server';
import { jwtVerify } from 'jose'; // This library supports Edge Runtime

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

  // No token, redirect to login page
  if (!token) {
    return NextResponse.redirect(new URL('/login', request.url));
  }

  try {
    // Verify JWT (using jose library, supports Edge Runtime)
    const secret = new TextEncoder().encode(process.env.JWT_SECRET);
    const { payload } = await jwtVerify(token, secret);

    // Verification passed, add user info to header (optional)
    const response = NextResponse.next();
    response.headers.set('x-user-id', payload.userId as string);

    return response;
  } catch (error) {
    // Token invalid, clear cookie and redirect
    const response = NextResponse.redirect(new URL('/login', request.url));
    response.cookies.delete('auth-token');
    return response;
  }
}

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

Key Points:

  • Use jose library instead of jsonwebtoken, because the latter depends on Node.js crypto module
  • JWT secret read from environment variables (process.env available in Edge Runtime)
  • When verification fails, clear cookie to avoid repeated failures

What If You Must Call Database?

Some scenarios really need database queries, like checking if user is banned. In this case, you can call an API route in Middleware:

export async function middleware(request: NextRequest) {
  const userId = request.cookies.get('user-id')?.value;

  if (!userId) {
    return NextResponse.redirect(new URL('/login', request.url));
  }

  // Call API route to verify user status
  const apiUrl = new URL('/api/check-user-status', request.url);
  const response = await fetch(apiUrl, {
    headers: { 'x-user-id': userId }
  });

  const { isActive } = await response.json();

  if (!isActive) {
    return NextResponse.redirect(new URL('/account-suspended', request.url));
  }

  return NextResponse.next();
}

API routes run on Node.js Runtime, can connect to database freely. But this increases latency, so only use when necessary.

About Web Crypto API

If you need encryption/decryption, use browser-native Web Crypto API:

// Generate hash
const data = new TextEncoder().encode('hello world');
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
const hashArray = Array.from(new Uint8Array(hashBuffer));
const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');

Honestly, this API is more cumbersome than Node.js crypto, but in Edge Runtime this is the only way.

How to Know If a Library Supports Edge Runtime?

Check docs or try directly. If it errors Native Node.js APIs are not supported, then it doesn’t support it.

Some popular libraries specifically provide Edge versions, like:

  • JWT: Use jose instead of jsonwebtoken
  • Database: Vercel Postgres, Supabase, Prisma (partial support)
  • Logging: Logtail, Axiom

My advice: Don’t do too complex things in Middleware. It should be fast in, fast out—heavy work goes to API routes.

Practical Cases: Three Core Scenarios Complete Implementation

Enough theory, time for code. These three cases are all from my actual projects, copy and run directly.

Case 1: Authentication & Route Protection

Scenario: You have an admin system, all pages under /dashboard require login to access.

Complete Code:

// middleware.ts
import { NextRequest, NextResponse } from 'next/server';
import { jwtVerify } from 'jose';

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

  // Get token
  const token = request.cookies.get('auth-token')?.value;

  // Not logged in, redirect to login page, record original URL
  if (!token) {
    const loginUrl = new URL('/login', request.url);
    loginUrl.searchParams.set('from', pathname); // Jump back after login
    return NextResponse.redirect(loginUrl);
  }

  try {
    // Verify JWT
    const secret = new TextEncoder().encode(process.env.JWT_SECRET);
    const { payload } = await jwtVerify(token, secret);

    // Optional: Check if token is about to expire, auto-renew
    const expiresAt = payload.exp as number;
    const now = Math.floor(Date.now() / 1000);
    const shouldRefresh = expiresAt - now < 3600; // Less than 1 hour, refresh

    const response = NextResponse.next();

    if (shouldRefresh) {
      // Here you can call API route to refresh token
      // Simplified example, omitted here
      response.headers.set('x-token-refresh-needed', 'true');
    }

    // Pass user info to page (optional)
    response.headers.set('x-user-id', payload.userId as string);
    response.headers.set('x-user-role', payload.role as string);

    return response;
  } catch (error) {
    // Token invalid or expired, clear and redirect
    const loginUrl = new URL('/login', request.url);
    loginUrl.searchParams.set('from', pathname);
    loginUrl.searchParams.set('reason', 'expired');

    const response = NextResponse.redirect(loginUrl);
    response.cookies.delete('auth-token');

    return response;
  }
}

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

Key Points:

  1. Use from parameter to record where user originally wanted to go, can jump back after login
  2. Check if token is about to expire, refresh early, avoid user suddenly logged out mid-operation
  3. Put user info in header, page components can read directly (optional)

Testing Method:

  • Clear browser cookies, visit /dashboard → Should redirect to /login?from=/dashboard
  • After login set cookie, visit again → Should display normally

Common Issues:

  • Issue: Works locally, doesn’t work after deployment
    Reason: Environment variable JWT_SECRET not configured in production
    Solution: Add environment variables in Vercel/Netlify platform settings

Case 2: Internationalization (i18n) Route Redirect

Scenario: Your website supports Chinese and English, when user visits /, automatically redirect to /zh or /en based on language preference.

Complete Code:

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

const supportedLocales = ['en', 'zh', 'ja'];
const defaultLocale = 'en';

function getPreferredLocale(request: NextRequest): string {
  // Priority 1: URL parameter (for manual switching)
  const urlLocale = request.nextUrl.searchParams.get('lang');
  if (urlLocale && supportedLocales.includes(urlLocale)) {
    return urlLocale;
  }

  // Priority 2: Cookie (user's last choice)
  const cookieLocale = request.cookies.get('NEXT_LOCALE')?.value;
  if (cookieLocale && supportedLocales.includes(cookieLocale)) {
    return cookieLocale;
  }

  // Priority 3: Browser language (Accept-Language header)
  const acceptLanguage = request.headers.get('accept-language');
  if (acceptLanguage) {
    // Simple parse "zh-CN,zh;q=0.9,en;q=0.8"
    const browserLang = acceptLanguage.split(',')[0].split('-')[0];
    if (supportedLocales.includes(browserLang)) {
      return browserLang;
    }
  }

  return defaultLocale;
}

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

  // Check if path already contains language prefix
  const pathnameHasLocale = supportedLocales.some(
    locale => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`
  );

  if (!pathnameHasLocale) {
    // No language prefix, redirect to path with language
    const locale = getPreferredLocale(request);
    const newUrl = new URL(`/${locale}${pathname}`, request.url);

    // Preserve query parameters
    newUrl.search = request.nextUrl.search;

    const response = NextResponse.redirect(newUrl);

    // Set cookie to remember user choice (30 days)
    response.cookies.set('NEXT_LOCALE', locale, {
      maxAge: 60 * 60 * 24 * 30,
      path: '/'
    });

    return response;
  }

  return NextResponse.next();
}

export const config = {
  matcher: [
    // Match all paths but exclude static resources and API
    '/((?!api|_next/static|_next/image|favicon.ico|.*\\.).*)'
  ]
}

Key Points:

  1. Three-layer language detection: URL parameter > Cookie > Browser settings
  2. Use Cookie to remember user choice, next visit directly use last language
  3. Matcher excludes static resources, avoid images also being redirected

Integration with next-intl Library:

If you use next-intl, can simplify a lot:

import { createI18nMiddleware } from 'next-intl/middleware';

export default createI18nMiddleware({
  locales: ['en', 'zh', 'ja'],
  defaultLocale: 'en'
});

export const config = {
  matcher: ['/((?!api|_next|.*\\.).)']
};

next-intl automatically handles language detection, Cookie management, saves a lot of trouble.

Case 3: A/B Testing & Feature Flags

Scenario: You redesigned the homepage, want 50% of users to see the new version, collect data before deciding whether to fully launch.

Complete Code:

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

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

  // Only do A/B test on homepage
  if (pathname !== '/') {
    return NextResponse.next();
  }

  // Check if user has been grouped
  let variant = request.cookies.get('ab-test-homepage')?.value;

  if (!variant) {
    // New user, randomly assign to A or B group
    variant = Math.random() < 0.5 ? 'A' : 'B';
  }

  let response: NextResponse;

  if (variant === 'B') {
    // B group: rewrite to new version page (URL unchanged)
    response = NextResponse.rewrite(new URL('/homepage-v2', request.url));
  } else {
    // A group: use original version
    response = NextResponse.next();
  }

  // Set cookie to ensure user grouping consistent (7 days)
  response.cookies.set('ab-test-homepage', variant, {
    maxAge: 60 * 60 * 24 * 7,
    path: '/'
  });

  // Add header to mark user's group (for analytics)
  response.headers.set('x-ab-variant', variant);

  return response;
}

export const config = {
  matcher: ['/']
}

Key Points:

  1. Use rewrite instead of redirect, user sees URL is still /, better experience
  2. Cookie ensures user grouping consistent, won’t see different version after refresh
  3. Mark group via header, convenient for analytics platforms to identify

Analytics Suggestion:

Read header in page component:

// app/page.tsx
import { headers } from 'next/headers';

export default function HomePage() {
  const headersList = headers();
  const abVariant = headersList.get('x-ab-variant');

  // Send analytics data
  useEffect(() => {
    analytics.track('page_view', {
      page: 'homepage',
      variant: abVariant
    });
  }, [abVariant]);

  return <div>...</div>;
}

This way you can see conversion rate differences between A/B groups in analytics dashboard.

Advanced: Group by User ID (Instead of Random)

If you want the same user to see the same version on different devices:

const userId = request.cookies.get('user-id')?.value;

if (userId) {
  // Assign group based on user ID hash (stable mapping)
  const hash = simpleHash(userId);
  variant = hash % 2 === 0 ? 'A' : 'B';
} else {
  // Unauthenticated users use cookie
  variant = request.cookies.get('ab-test-homepage')?.value ||
            (Math.random() < 0.5 ? 'A' : 'B');
}

// Simple hash function
function simpleHash(str: string): number {
  let hash = 0;
  for (let i = 0; i < str.length; i++) {
    hash = ((hash << 5) - hash) + str.charCodeAt(i);
    hash |= 0;
  }
  return Math.abs(hash);
}

These three cases basically cover the most common Middleware usage. You can combine them, like adding internationalization on top of authentication.

Performance Optimization & Best Practices

Writing Middleware is easy, writing it well needs some skill.

Modularize Middleware Logic

After projects grow, stuffing all logic in one middleware.ts becomes a mess. Although Next.js only allows one middleware file, you can split logic into multiple function modules.

Recommended Directory Structure:

Project Root/
├── middleware/
│   ├── auth.ts        # Authentication logic
│   ├── i18n.ts        # Internationalization logic
│   ├── ab-test.ts     # A/B testing logic
│   └── rate-limit.ts  # Rate limiting logic
├── middleware.ts      # Entry file

Entry File Example:

// middleware.ts
import { NextRequest, NextResponse } from 'next/server';
import { checkAuth } from './middleware/auth';
import { handleI18n } from './middleware/i18n';
import { handleABTest } from './middleware/ab-test';

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

  // 1. Handle internationalization first
  const i18nResponse = handleI18n(request);
  if (i18nResponse) return i18nResponse;

  // 2. Then check authentication
  if (pathname.startsWith('/dashboard')) {
    const authResponse = await checkAuth(request);
    if (authResponse) return authResponse;
  }

  // 3. Finally handle A/B testing
  if (pathname === '/') {
    return handleABTest(request);
  }

  return NextResponse.next();
}

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

auth.ts Example:

// middleware/auth.ts
import { NextRequest, NextResponse } from 'next/server';
import { jwtVerify } from 'jose';

export async function checkAuth(request: NextRequest): Promise<NextResponse | null> {
  const token = request.cookies.get('auth-token')?.value;

  if (!token) {
    return NextResponse.redirect(new URL('/login', request.url));
  }

  try {
    const secret = new TextEncoder().encode(process.env.JWT_SECRET);
    await jwtVerify(token, secret);
    return null; // Verification passed, return null to continue
  } catch {
    return NextResponse.redirect(new URL('/login', request.url));
  }
}

This way each module has clear responsibilities, easier to modify.

Caching Strategy: Reduce Redundant Computation

Some logic can be cached to avoid recomputing on every request. Like user permission checks—if token is valid, don’t need to verify every time.

Use Vercel Edge Config to Cache Config:

import { get } from '@vercel/edge-config';

export async function middleware(request: NextRequest) {
  // Read feature flags from Edge Config (already cached)
  const featureFlags = await get('feature-flags');

  if (featureFlags?.newDashboard) {
    return NextResponse.rewrite(new URL('/dashboard-v2', request.url));
  }

  return NextResponse.next();
}

Edge Config is Vercel’s globally distributed key-value store, extremely fast reads, perfect for storing rarely-changing config.

Avoid Oversized Headers

Headers set in Middleware are attached to responses. If headers are too many or too large, may cause 431 Request Header Fields Too Large error.

Suggestions:

  • Keep total header size under 8KB
  • Only pass necessary info, don’t stuff entire user object in
  • If need to pass large data, consider encrypted token

Wrong Example:

// ❌ Don't do this
response.headers.set('x-user-data', JSON.stringify(userData)); // May be huge

Correct Approach:

// ✅ Only pass key info
response.headers.set('x-user-id', user.id);
response.headers.set('x-user-role', user.role);

Matcher Optimization: Precise Matching Better Than Wildcards

More precise matcher, higher Next.js execution efficiency.

Not Great Approach:

export const config = {
  matcher: ['/:path*'] // Match all paths
}

Better Approach:

export const config = {
  matcher: ['/dashboard/:path*', '/api/:path*'] // Only match what's needed
}

If your Middleware only needs to protect a few specific routes, don’t use wildcards to match all.

Monitoring & Debugging

Debugging in Development:

export function middleware(request: NextRequest) {
  if (process.env.NODE_ENV === 'development') {
    console.log('Middleware executed:', {
      path: request.nextUrl.pathname,
      method: request.method,
      cookies: request.cookies.getAll()
    });
  }

  // Your logic...
}

Production Logging:

console.log in Edge Runtime outputs to platform logs (like Vercel’s Edge Function Logs). But don’t log too much, increases execution time.

Recommended Logging Solution: Send to Third-Party Service

import { Logger } from '@logtail/edge';

const logger = new Logger(process.env.LOGTAIL_TOKEN);

export async function middleware(request: NextRequest) {
  try {
    // Your logic...
  } catch (error) {
    // Only log on error
    await logger.error('Middleware error', {
      path: request.nextUrl.pathname,
      error: error.message
    });
    throw error;
  }
}

Best Practices Checklist

Summary of experience from years of stepping into pitfalls:

✅ Should Do:

  • Keep Middleware logic lightweight, complex logic goes to API routes
  • Use matcher to precisely match paths that need handling
  • Use JWT for authentication, avoid database queries
  • Modularize code, split files by function
  • Print debug info in development
  • Set reasonable Cookie expiration times

❌ Shouldn’t Do:

  • Do heavy computation or database operations in Middleware
  • Don’t configure matcher, let all requests execute
  • Use third-party libraries that depend on Node.js APIs
  • Set oversized headers (>8KB)
  • Log heavily in production
  • Create infinite redirect loops (check logic to avoid recursion)

Follow these principles, your Middleware should be fine.

Common Errors & Debugging Tips

Finally, let’s talk about those frustrating errors. I’ve encountered all of these, some took quite a while.

Error 1: Middleware Doesn’t Execute at All

Symptoms: Wrote Middleware, but seems like it didn’t work, requests pass normally.

Possible Causes & Solutions:

CauseCheck MethodSolution
File Location WrongConfirm middleware.ts in root or src directoryMove to correct location
Matcher Doesn’t Cover Current PathPrint request.nextUrl.pathname to see pathAdjust matcher config
Syntax ErrorCheck console for compilation errorsFix syntax issues
Didn’t Export Function CorrectlyConfirm using export function middlewareCheck export syntax
Cache IssueClear .next directoryRun rm -rf .next && npm run dev

Debugging Tip:

Add a log line at the very beginning of Middleware:

export function middleware(request: NextRequest) {
  console.log('🔥 Middleware executed! Path:', request.nextUrl.pathname);
  // Other logic...
}

If this log doesn’t print, Middleware didn’t execute, check the above causes.

Error 2: Native Node.js APIs are not supported in the Edge Runtime

Symptoms: Runtime error, points out some API or module not supported.

Locate Problem:

Look at error stack, find which library or code called Node.js API. Common culprits:

  • fs, path and other file system modules
  • jsonwebtoken (use jose instead)
  • MongoDB, MySQL native drivers (use Edge-compatible libraries)

Solutions:

  1. Find Alternative Library: Check library docs, see if there’s Edge Runtime compatible version
  2. Move Logic to API Route: If must use Node.js API, don’t put it in Middleware
  3. Use Web Standard APIs: Like use Web Crypto API instead of Node.js crypto

Example:

// ❌ Won't work
import jwt from 'jsonwebtoken';

// ✅ Use this
import { jwtVerify } from 'jose';

Error 3: Invalid middleware found

Symptoms: Error when starting project.

Possible Causes:

1. Matcher is Empty Array

// ❌ Won't work
export const config = {
  matcher: []
}

2. Didn’t Export Middleware Function Correctly

// ❌ Won't work
const middleware = (request: NextRequest) => { ... }

// ✅ Must do this
export function middleware(request: NextRequest) { ... }

3. Middleware Function Has No Return Value

// ❌ Won't work
export function middleware(request: NextRequest) {
  console.log('Do something');
  // Forgot to return
}

// ✅ Must have return value
export function middleware(request: NextRequest) {
  return NextResponse.next();
}

Error 4: Infinite Redirect Loop

Symptoms: Browser reports ERR_TOO_MANY_REDIRECTS, page won’t load.

Cause: Redirect target URL triggers Middleware again, causing loop.

Common Scenario:

export function middleware(request: NextRequest) {
  const token = request.cookies.get('auth-token');

  if (!token) {
    // ❌ Problem: Redirect to /login, but /login also triggers this Middleware
    return NextResponse.redirect(new URL('/login', request.url));
  }

  return NextResponse.next();
}

export const config = {
  matcher: ['/:path*'] // Matches all paths, including /login
}

Solution: Exclude login page or other public pages

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

  // ✅ Exclude public pages first
  if (pathname === '/login' || pathname === '/') {
    return NextResponse.next();
  }

  const token = request.cookies.get('auth-token');

  if (!token) {
    return NextResponse.redirect(new URL('/login', request.url));
  }

  return NextResponse.next();
}

Or adjust matcher:

export const config = {
  matcher: ['/dashboard/:path*'] // Only protect paths that need login
}

Error 5: Environment Variables Not Readable

Symptoms: process.env.XXX is undefined.

Causes:

  1. .env.local file not created or variable name misspelled
  2. Deployment platform didn’t configure environment variables (like Vercel, Netlify)
  3. Variable name doesn’t meet standards (Next.js has some restrictions)

Solutions:

Local Development:

// .env.local
JWT_SECRET=your-secret-here

Production:
Add environment variables in Vercel/Netlify project settings, redeploy.

Note:

  • Need to restart dev server after modifying environment variables
  • Deployment platform environment variables only available on server and Edge Runtime, client needs NEXT_PUBLIC_ prefix

Debugging Tips Summary

1. Use x-middleware-next Header to Track

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

  // Mark that this request went through Middleware
  response.headers.set('x-middleware-executed', 'true');
  response.headers.set('x-middleware-path', request.nextUrl.pathname);

  return response;
}

Then check response headers in browser dev tools to confirm if Middleware executed.

2. Comment Out Sections, Locate Problem

If unsure which line causes error, gradually comment out Middleware logic:

export function middleware(request: NextRequest) {
  console.log('Step 1');
  // ... Some logic

  console.log('Step 2');
  // ... More logic

  console.log('Step 3');
  return NextResponse.next();
}

See which step logs print to, know where the problem is.

3. Check Vercel’s Edge Function Logs

If deployed to Vercel, in project’s Functions tab you can see Edge Function logs, including console.log output and error messages.

4. Use next dev --turbo for Local Testing

Next.js 15+ supports Turbo mode, starts faster, error messages clearer:

npm run dev -- --turbo

Don’t panic when encountering problems, troubleshoot step by step with these methods, you’ll find the cause. If really stuck, search Next.js GitHub Discussions or Stack Overflow, many pitfalls others have already stepped into.

Summary

After all this, summarized into three sentences:

1. Clarify Middleware’s Applicable Boundaries

Don’t stuff everything in. It’s suitable for lightweight checks and forwarding—authentication, route redirects, A/B testing. Complex business logic, database queries, heavy computation—leave to API routes or server components. Remember: Middleware should be fast in, fast out, like a competent gatekeeper, not a butler.

2. Matcher Configuration Is Top Priority

This is where you’ll most easily step into pitfalls, and what I’ve emphasized repeatedly in this article. Remember to add * for dynamic routes, exclude static resources, don’t intercept public pages (like login). If really unclear, use the templates I provided, or log in development to see path matching.

3. Understand and Accept Edge Runtime Limitations

Edge Runtime isn’t full Node.js—this is a design trade-off—sacrificing capability for speed and global distribution. Know which APIs can’t be used, know how to work around these limitations (JWT, Web Crypto API, API route calls), and you can use it well.

Next.js Middleware isn’t complex, just has many details. Matcher rules, Edge Runtime limitations, infinite redirect traps—I’ve stepped into all these pitfalls, wrote this article to help you avoid detours.

If you’re working on features that need global interception, try Middleware. After code runs, optimize gradually. When encountering problems, flip back to the error troubleshooting chapter, should be able to solve most.

Finally, if this article helped you, welcome to share with friends also using Next.js. Next article I plan to write about Server Actions, also a topic with many pitfalls.

Good luck, hope your Middleware runs through on the first try.

FAQ

What if Middleware matcher configuration doesn't work?
Check:
1) Is the matcher path correct? (Dynamic routes need *)
2) Are static assets excluded?
3) Is the path format correct? (Don't use regex, use Next.js supported format)

You can add console.log in middleware to see which paths are matched.
Why don't multi-level paths match?
Dynamic routes must use `:path*` (note the asterisk) to match multi-level paths.

For example, `/dashboard/:path*` can match `/dashboard/settings/profile`, while `/dashboard/:path` only matches one-level paths like `/dashboard/settings`.
What if Edge Runtime doesn't support a library?
Edge Runtime isn't full Node.js and doesn't support all npm packages.

Solutions:
1) Check if the package supports Edge Runtime
2) Use Web standard APIs instead
3) Move complex logic to API routes or Server Components
4) Use alternative libraries that support Edge Runtime
How do I avoid infinite redirect loops?
Ensure redirect targets are not in the matcher matching range.

For example, if redirecting to `/login`, the matcher should exclude the `/login` path.

You can use negative lookahead: `'/((?!login|api).*)'`
Can Middleware access databases?
Not recommended. Middleware runs on Edge Runtime and should stay lightweight.

If you need database queries:
1) Call API routes from Middleware
2) Use environment variables to store configuration
3) Move complex logic to API routes or Server Components
How do I debug Middleware?
Methods:
1) Add console.log in middleware function to output debug info
2) Check browser Network tab to view requests and responses
3) View Vercel Edge Functions logs
4) Use Next.js dev mode to view warnings and errors
What's the difference between Middleware and API routes?
Middleware:
• Runs on Edge Runtime
• Executes before requests reach pages or API routes
• Suitable for lightweight interception and forwarding

API routes:
• Run on Node.js runtime
• Can access full Node.js APIs and databases
• Suitable for complex business logic

16 min read · Published on: Dec 25, 2025 · Modified on: Jan 22, 2026

Comments

Sign in with GitHub to leave a comment

Related Posts