Middleware in Next.js

1. Introduction 🚀

Middleware lets you run code before a request completes, giving you a single place to handle redirects, rewrites, authentication checks, and header manipulation — all before a page or Route Handler ever runs. This tutorial covers everything from the basic file setup to advanced patterns like RBAC and internationalization.

Information

Middleware runs on the Edge Runtime by default and applies to every route in your project unless scoped with a matcher.

2. What is Middleware? 🤔

Middleware is a single function, exported from a middleware.ts file at the root of your project, that intercepts requests before they reach a route. It can inspect, modify, redirect, or short-circuit the request entirely.

  • Runs before cache and route matching.
  • Ideal for cross-cutting concerns: auth, logging, i18n, feature flags.
  • Built on the same Request/Response Web APIs as Route Handlers.

3. How Middleware Works âš™ī¸

When a request comes in, Next.js checks whether it matches your middleware.ts config. If it does, the exported middleware() function runs first, and its returned NextResponse decides what happens next: continue, redirect, rewrite, or return a direct response.

Incoming Request
Matcher Check
middleware() runs
Route / Page / Route Handler
NextResponse.next()
NextResponse.redirect()
NextResponse.rewrite()

4. Middleware File Structure 📁

Middleware MUST live in a single middleware.ts (or .js) file at the root of your project — either directly inside the project root or inside src if you use that convention.

my-app
package.json

Warning

Only one middleware.ts file is allowed per project. All logic — auth, i18n, headers — must be composed within that single entry point.

5. Request Lifecycle 🔄

6. Request Inspection 🔍

The middleware function receives a NextRequest object, giving you access to the URL, headers, cookies, and geo/IP information (when deployed on supporting infrastructure).

middleware.ts

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

export function middleware(request: NextRequest) {
  console.log(request.nextUrl.pathname);
  console.log(request.method);
  return NextResponse.next();
}

7. Response Modification âœī¸

Middleware can modify the outgoing response by cloning headers, injecting new ones, or attaching cookies before passing the request along with NextResponse.next().

middleware.ts

import { NextResponse } from 'next/server';

export function middleware(request: Request) {
  const response = NextResponse.next();
  response.headers.set('X-Powered-By', 'My-App');
  return response;
}

8. Redirects â†Ēī¸

Use NextResponse.redirect() to send the client to a different URL, optionally with a specific status code such as 307 or 308.

middleware.ts

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

export function middleware(request: NextRequest) {
  if (request.nextUrl.pathname === '/old-page') {
    return NextResponse.redirect(new URL('/new-page', request.url));
  }
  return NextResponse.next();
}

9. Rewrites 🔀

A rewrite serves content from a different path while keeping the URL in the browser unchanged — useful for A/B testing, proxying, or masking internal routes.

middleware.ts

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

export function middleware(request: NextRequest) {
  if (request.nextUrl.pathname === '/dashboard') {
    return NextResponse.rewrite(new URL('/dashboard/v2', request.url));
  }
  return NextResponse.next();
}

Tip

Redirects change the URL the browser shows; rewrites don't. Use rewrites when you want the change to be invisible to the user.

10. Authentication 🔐

Middleware is a natural place to check whether a request carries a valid session before any page code runs, avoiding wasted rendering work for unauthenticated users.

middleware.ts

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

export function middleware(request: NextRequest) {
  const token = request.cookies.get('session')?.value;
  if (!token) {
    return NextResponse.redirect(new URL('/login', request.url));
  }
  return NextResponse.next();
}

11. Authorization đŸ›Ąī¸

Once a user is authenticated, middleware can also verify what they're allowed to access — for example, decoding a JWT to check a permission claim.

middleware.ts

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

export function middleware(request: NextRequest) {
  const token = request.cookies.get('session')?.value;
  const payload = token ? decodeJwt(token) : null;

  if (!payload?.permissions?.includes('read:reports')) {
    return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
  }
  return NextResponse.next();
}

12. Route Protection 🚧

Combine a matcher with an authentication check to guard entire route groups, like /dashboard or /admin, without repeating logic in every page.

middleware.ts

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

export function middleware(request: NextRequest) {
  const isLoggedIn = Boolean(request.cookies.get('session'));
  if (!isLoggedIn) {
    return NextResponse.redirect(new URL('/login', request.url));
  }
  return NextResponse.next();
}

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

13. Role-Based Access Control

RBAC restricts specific paths to specific user roles. Middleware can read a role claim from the session and branch accordingly.

middleware.ts

export function middleware(request: NextRequest) {
  const role = getUserRole(request);

  if (request.nextUrl.pathname.startsWith('/admin') && role !== 'admin') {
    return NextResponse.redirect(new URL('/unauthorized', request.url));
  }
  return NextResponse.next();
}

Best Practice

Keep role logic centralized in a helper function (like getUserRole) so it stays consistent between middleware and your Route Handlers.

14. Cookies đŸĒ

request.cookies and response.cookies provide a simple API for reading and setting cookies directly from middleware.

middleware.ts

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

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

  if (!visited) {
    response.cookies.set('visited', 'true', { maxAge: 60 * 60 * 24 });
  }
  return response;
}

15. Headers đŸˇī¸

You can read incoming headers via request.headers and set outgoing ones on the NextResponse you return.

middleware.ts

export function middleware(request: NextRequest) {
  const country = request.headers.get('x-vercel-ip-country');
  const response = NextResponse.next();
  response.headers.set('X-User-Country', country ?? 'unknown');
  return response;
}

16. URL Matching đŸŽ¯

Inside the middleware function itself, you can branch on request.nextUrl.pathname to apply different logic to different paths.

middleware.ts

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

  if (pathname.startsWith('/api')) {
    return handleApiRequest(request);
  }
  if (pathname.startsWith('/blog')) {
    return handleBlogRequest(request);
  }
  return NextResponse.next();
}

17. Matcher Configuration 🧭

The exported config.matcher tells Next.js which paths should invoke middleware at all — this is more efficient than running the function on every request and filtering inside it.

middleware.ts

export const config = {
  matcher: [
    '/dashboard/:path*',
    '/((?!_next/static|_next/image|favicon.ico).*)',
  ],
};
PatternMeaning
/dashboard/:path*Matches /dashboard and all nested paths
/((?!_next).*)Matches everything except internal Next.js assets

18. Conditional Middleware 🔀

Beyond the static matcher, you can add runtime conditions inside the function itself — for example, skipping logic for certain user agents or feature flags.

middleware.ts

export function middleware(request: NextRequest) {
  const featureEnabled = request.cookies.get('beta')?.value === 'true';

  if (!featureEnabled) {
    return NextResponse.next();
  }
  return NextResponse.rewrite(new URL('/beta-home', request.url));
}

19. Internationalization (i18n) 🌍

Middleware is a common place to detect a visitor's preferred locale — from the Accept-Language header or a cookie — and rewrite or redirect to the localized path.

middleware.ts

const locales = ['en', 'fr', 'de'];

export function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl;
  const hasLocale = locales.some((locale) => pathname.startsWith(`/${locale}`));

  if (!hasLocale) {
    const preferred = request.headers.get('accept-language')?.split(',')[0].slice(0, 2) ?? 'en';
    const locale = locales.includes(preferred) ? preferred : 'en';
    return NextResponse.redirect(new URL(`/${locale}${pathname}`, request.url));
  }
  return NextResponse.next();
}

20. Localization

Once a locale is detected, you can also persist the user's choice in a cookie so future visits skip the detection step entirely.

middleware.ts

export function middleware(request: NextRequest) {
  const response = NextResponse.next();
  const locale = request.nextUrl.pathname.split('/')[1];

  if (locale) {
    response.cookies.set('NEXT_LOCALE', locale);
  }
  return response;
}

21. Rate Limiting đŸšĻ

Middleware can enforce simple rate limits by tracking request counts per IP address, typically backed by an external store like Redis or an edge-compatible KV store.

middleware.ts

export async function middleware(request: NextRequest) {
  const ip = request.headers.get('x-forwarded-for') ?? 'unknown';
  const count = await incrementRequestCount(ip);

  if (count > 100) {
    return NextResponse.json({ error: 'Too many requests' }, { status: 429 });
  }
  return NextResponse.next();
}

Warning

Middleware runs on the Edge Runtime, so rate-limit state MUST live in an external, edge-accessible store — in-memory counters won't persist across invocations.

22. Bot Detection 🤖

Inspecting the User-Agent header lets you flag or block known crawlers and bots before they reach expensive routes.

middleware.ts

export function middleware(request: NextRequest) {
  const userAgent = request.headers.get('user-agent') ?? '';
  const isBot = /bot|crawler|spider/i.test(userAgent);

  if (isBot && request.nextUrl.pathname.startsWith('/checkout')) {
    return NextResponse.json({ error: 'Not allowed' }, { status: 403 });
  }
  return NextResponse.next();
}

23. Security Headers 🔒

Middleware is a convenient single place to attach security-related headers — like Content-Security-Policy or X-Frame-Options — to every response.

middleware.ts

export function middleware(request: NextRequest) {
  const response = NextResponse.next();
  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');
  return response;
}

24. CORS 🌐

Middleware can centrally enforce CORS rules for API-like routes, including handling OPTIONS preflight requests.

middleware.ts

export function middleware(request: NextRequest) {
  if (request.method === 'OPTIONS') {
    return new NextResponse(null, {
      headers: {
        'Access-Control-Allow-Origin': 'https://example.com',
        'Access-Control-Allow-Methods': 'GET, POST',
      },
    });
  }
  const response = NextResponse.next();
  response.headers.set('Access-Control-Allow-Origin', 'https://example.com');
  return response;
}

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

25. Edge Runtime ⚡

Middleware always runs on the Edge Runtime — a lightweight V8 environment deployed close to the user. This keeps middleware fast, but it means full Node.js APIs are unavailable.

  • No direct file system access (fs is unavailable).
  • Limited subset of Node.js built-in modules.
  • Favor fetch-based calls to external services over native drivers.

26. Performance Considerations đŸŽī¸

  • Keep middleware logic lightweight — it runs on every matched request.
  • Scope execution tightly with config.matcher instead of checking paths manually inside the function.
  • Avoid expensive synchronous computation; prefer async calls to fast, edge-compatible data stores.
  • Cache results (e.g. decoded JWT claims) where possible to avoid repeated work per request.

27. Debugging Middleware 🐞

Since middleware runs on the edge, console.log output appears in your terminal during local development and in your hosting provider's edge logs in production.

middleware.ts

export function middleware(request: NextRequest) {
  console.log('[middleware]', request.method, request.nextUrl.pathname);
  return NextResponse.next();
}

Tip

Add a distinct log prefix like [middleware] so its output is easy to filter apart from route-level logs.

28. Middleware Limitations đŸšĢ

  • Only one middleware.ts file is allowed per project.
  • No access to the full Node.js runtime (no fs, no native modules).
  • Cannot directly read a request body for use in routing decisions without extra handling.
  • Adds latency to every matched request, so scope and complexity matter.

29. Best Practices ✅

  1. Scope middleware with a precise matcher — never run it project-wide unless truly necessary.
  2. Keep authentication checks fast; verify tokens rather than performing full database lookups when possible.
  3. Centralize security headers here instead of duplicating them in every route.
  4. Return early with NextResponse.next() for requests that don't need special handling.
  5. Log meaningful context (path, method, decision) for easier debugging in production.

30. Common Mistakes đŸšĢ

Common Middleware Mistakes
Omitting config.matcher, causing middleware to run on every single request
Trying to use Node.js-only APIs like fs inside middleware
Redirect loops
Storing rate-limit counters in memory, which don't persist across edge invocations
Redirecting /login back to itself when already unauthenticated
Forgetting to exclude static assets from the matcher

31. Frequently Asked Questions ❓

Middleware can call request.json() or similar methods, but doing so consumes the body stream — take care if the route handler downstream also needs to read it.

No — Next.js supports exactly one middleware.ts file at the project root. Compose all logic within that single function.

Yes, if the path matches your config.matcher — middleware runs before Next.js determines whether a page is static or dynamic.

32. Summary 📚