DH
11 min read

Next.js Middleware: What It's For, What It Isn't, and How to Ship It Correctly

Master what Next.js middleware is, what it isn't, and when to use it. Learn the architecture judgment that keeps middleware fast and maintainable.

nextjsperformancesecurity

You've probably reached for middleware to solve an auth check, and then found yourself tempted to shove logging, geolocation, A/B testing, and rate limiting into the same file because it's the one place that touches every request. That instinct is understandable and it's also how middleware quietly becomes the slowest, hardest-to-debug part of your app.

Here's the direct answer: Next.js middleware is a function that runs before a request completes, letting you inspect and modify the request or response — redirect, rewrite, set headers, read or write cookies — before a page, API route, or static asset is served. It runs on every matching request, in a constrained runtime, before your normal route handlers. That's the whole contract. Everything else is judgment about what belongs there.

This piece gives you the working code first, then the config knobs, then the part that actually matters: where middleware earns its keep and where it becomes the reason your TTFB graph looks wrong.

What is Next.js middleware, exactly?

Middleware sits in the request pipeline between the network and your routing layer. Before Next.js resolves a request to a page, a Route Handler, or a static file, it gives your middleware function a chance to look at the incoming request and decide: let it through unchanged, redirect it somewhere else, rewrite it to a different internal path, or modify the request/response headers and cookies before continuing.

Conceptually it's the same idea as middleware in Express, Django, or any other framework that supports a request-interception layer — a single chokepoint you can use for cross-cutting concerns that don't belong inside individual route logic. The difference in Next.js is where it runs and how constrained that environment is, which is the part people skip past and then get bitten by later.

Good middleware use cases share a pattern: they're cheap, they're global or near-global in scope, and they need to happen before rendering starts. Auth gating, locale/geolocation redirects, feature-flag routing, and basic bot/header checks fit that shape well. Things that don't fit: anything with meaningful compute, anything that needs a database round-trip on every request, and anything that's really page-specific logic wearing a global-concern costume.

Creating the middleware file and exporting the handler

Here's a minimal, working example:

// proxy.ts (Next.js 15+) or middleware.ts (earlier versions)
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

export function proxy(request: NextRequest) {
// Read something off the incoming request
const country = request.headers.get('x-vercel-ip-country') ?? 'unknown'

// Attach it as a request header downstream code can read
const response = NextResponse.next()
response.headers.set('x-user-country', country)

return response
}

The config object and matcher: scoping middleware to routes

Run this function on every request in your app and you've built a tax on every single page load, including static assets you don't need to touch. That's the single most common middleware mistake I see: no matcher, or a matcher that's too broad, quietly adding latency to routes that never needed the function to run at all.

The config object, exported alongside your handler, lets you scope execution using a matcher:

// proxy.ts
export const config = {
matcher: ['/dashboard/:path*', '/account/:path*'],
}

This restricts your proxy/middleware function to run only on /dashboard/* and /account/* — not on /, not on /api/health, not on every image request. Matchers support path patterns with named parameters (the :path* syntax borrowed from path-to-regexp conventions), and you can also express matching with regex-like array entries when you need finer control — for example excluding specific static asset paths.

A practical default worth adopting: write the matcher first, before you write the handler logic. Ask "which routes actually need this?" as a design question, not an afterthought. If the honest answer is "basically everything," that's a signal to double-check whether the logic really needs to run pre-render for every request, or whether it can live closer to the route that actually needs it.

Within the handler, request.nextUrl gives you parsed URL info — pathname, search params, and any dynamic segments captured by your matcher — so you can branch logic based on which matched route you're in without re-parsing the URL yourself.

NextResponse: redirect, rewrite, and modifying headers/cookies

NextResponse is the object you'll return from almost every middleware function, and it has four operations worth knowing cold.

Redirect — send the browser to a different URL, changing the address bar:

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

export function proxy(request: NextRequest) {
const isLoggedIn = request.cookies.has('session')

if (!isLoggedIn && request.nextUrl.pathname.startsWith('/dashboard')) {
const loginUrl = new URL('/login', request.url)
loginUrl.searchParams.set('from', request.nextUrl.pathname)
return NextResponse.redirect(loginUrl)
}

return NextResponse.next()
}

Rewrite — serve different content at the same URL, invisibly to the browser. Common for A/B tests, locale-based content, or serving a maintenance page without changing the URL:

export function proxy(request: NextRequest) {
if (request.nextUrl.pathname === '/pricing') {
const bucket = request.cookies.get('ab-bucket')?.value ?? 'a'
if (bucket === 'b') {
return NextResponse.rewrite(new URL('/pricing-variant-b', request.url))
}
}
return NextResponse.next()
}

Modifying request headers — useful for passing computed context (like a decoded user ID) downstream to Server Components or Route Handlers without re-deriving it there:

export function proxy(request: NextRequest) {
const requestHeaders = new Headers(request.headers)
requestHeaders.set('x-request-id', crypto.randomUUID())

return NextResponse.next({
request: { headers: requestHeaders },
})
}

Cookies — read with request.cookies.get(), write with response.cookies.set():

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

if (!request.cookies.has('visitor-id')) {
response.cookies.set('visitor-id', crypto.randomUUID(), {
httpOnly: true,
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 365,
})
}

return response
}

That covers the practical surface area: redirect, rewrite, header mutation, cookie read/write. Everything else is composition of these four operations with your own conditional logic.

Execution order and runtime constraints

Middleware runs before route matching resolves to a page, layout, or Route Handler — which means it also runs before any of your data-fetching or auth logic inside those routes. That ordering is exactly why it's attractive for auth gating: reject or redirect before any downstream work happens, and you save the wasted render.

  • Reading and writing headers/cookies is cheap and supported
  • fetch calls work, but every call adds latency to every matching request
  • Avoid heavy libraries, filesystem access, or anything assuming a full Node runtime

Because middleware runs on every matching request before rendering, anything slow inside it becomes a floor under your app's latency — not an average, a floor. A 200ms database call in middleware means every gated page load is at least 200ms slower, full stop, regardless of how fast your rendering is.

Logging in middleware

Middleware is a genuinely good place for lightweight, structural request logging — capturing method, path, matched route, and timing metadata to forward to an observability pipeline — because it's the one place guaranteed to see every request that matches your matcher, before any route-specific logic runs.

The trap is treating middleware logging as your only logging layer, or making it synchronous and blocking. Fire-and-forget a log event (or batch it) rather than awaiting a network call before returning your response — a middleware function that blocks on a logging endpoint before it can redirect or rewrite has turned an observability nicety into a performance liability. If you need rich, structured logs with request bodies or full response payloads, that's usually better handled at the route or API layer where you have the full runtime and don't pay the latency tax on every single request.

Rate limiting in middleware

Rate limiting is one of the most commonly cited middleware use cases, and it's a legitimate one — checking a request's IP or token against a counter before it reaches expensive downstream logic is exactly the kind of cheap, early-rejection work middleware is built for.

The catch is state. Middleware itself doesn't give you a built-in counter or storage mechanism — you need an external store (a fast key-value store, an edge-compatible cache, or similar) that the middleware function can check on every request without meaningfully adding latency. If your rate-limit check requires a slow round-trip, you've just added that round-trip's latency to every request, defeating the "cheap early gate" premise that made middleware attractive in the first place.

A sane middleware rate-limiting pattern looks like: extract an identifier (IP, API key, session token) from the request, check it against a fast external store, and either continue or return a 429 response — all without touching your database or any heavyweight service. If your rate-limiting logic needs more nuance — different limits per plan tier, burst allowances, sliding windows with complex bookkeeping — consider whether that logic is better owned by an API gateway or a dedicated Route Handler where you have more room to work.

When middleware is the wrong tool

This is the section that actually saves you time in production, so take it seriously.

Middleware is the wrong tool when the work is expensive. Database queries, third-party API calls with unpredictable latency, anything CPU-heavy — all of it runs on the hot path of every matching request, before caching or rendering can help you. If you find yourself awaiting a slow call inside middleware, ask whether that check can move to the specific route that needs it, where it can be cached, memoized, or run in parallel with other work instead of gating everything upfront.

Middleware is the wrong tool for page-specific logic. If a check only matters for one route, put it in that route — a layout, a Server Component, a Route Handler. Middleware's value is in being global or near-global; using it for narrow, single-page concerns just adds an extra file and an extra mental hop for anyone reading the code later, without buying you anything.

Middleware is the wrong tool when the runtime doesn't support what you need. If your check depends on a full Node API, a large SDK, or filesystem access, forcing it into middleware's constrained runtime is a fight you'll lose, and you'll lose it in a way that's annoying to debug because the failure often looks unrelated to the actual cause.

Middleware is the wrong tool as your primary auth authorization layer, as opposed to a coarse authentication gate. Redirecting unauthenticated users away from /dashboard is a great middleware job. Fine-grained, per-resource authorization ("can this specific user edit this specific record") almost always needs data your middleware shouldn't be fetching on every request — that belongs in the route or API layer, where you already have the request's full context loaded.

The underlying principle: middleware's power comes from running early and running on everything that matches. Every use case that plays to that strength is a good fit. Every use case that fights it — needing heavy compute, narrow scope, or a full runtime — is a sign you've picked the convenient tool instead of the correct one.

Where this leaves you

You now have the full working pattern: a proxy.ts (or middleware.ts) file exporting a handler, a config.matcher scoping it to the routes that actually need it, and NextResponse operations for redirects, rewrites, and header/cookie mutation — plus the judgment to know when a database call or heavy logic means middleware is the wrong layer entirely.

If you're building a Next.js application and need deeper guidance on structuring auth, full-stack patterns, or container deployment alongside middleware decisions, Damian Hodgkiss publishes production-focused tutorials covering Next.js architecture, authentication flows, and deployment practices.

FAQ

Is middleware.ts still supported, or do I need to rename it to proxy.ts?

The naming convention depends on your Next.js version. In Next.js 14 and earlier, the file is named middleware.ts with an exported middleware function. Starting in Next.js 16+, the convention has shifted to proxy.ts (or .js) with an exported proxy function; the earlier middleware convention is now deprecated (see Next.js proxy documentation). Check your installed Next.js version before copying examples — mixing naming conventions across versions silently breaks execution.

Can I use multiple middleware files in one app? No — there's a single middleware/proxy file at your project root (or src/) that applies to the whole app. If you need different logic for different route groups, branch on request.nextUrl.pathname inside that one function, or scope separate concerns with careful matcher design.

Does middleware run on static assets and API routes too? It runs on anything that matches your matcher config. If you don't set an explicit matcher, the default is very broad. Always set a matcher rather than relying on defaults, especially if you're doing anything beyond trivial header inspection.

Can middleware access a database directly? Technically you can attempt a network call to a database, but the constrained runtime and the "runs on every matching request" nature make this a performance risk. Prefer fast external stores (cache/KV-style) for anything middleware needs to check, and leave full database access to your route handlers.

Is middleware a replacement for authentication logic in my API routes? No — treat it as a coarse gate (logged in or not, has a valid token or not), not as your full authorization system. Fine-grained permission checks belong closer to the data they're protecting.

Damian Hodgkiss

Damian Hodgkiss

Senior Staff Engineer at Sumo Group, leading development of AppSumo marketplace. Technical solopreneur with 25+ years of experience building SaaS products.

Creating Freedom

Join me on the journey from engineer to solopreneur. Learn how to build profitable SaaS products while keeping your technical edge.

    Proven strategies

    Learn the counterintuitive ways to find and validate SaaS ideas

    Technical insights

    From choosing tech stacks to building your MVP efficiently

    Founder mindset

    Transform from engineer to entrepreneur with practical steps