Migrate to RunxBuild and earn up to $50 in hosting credit on your first deposit.

Calculate your savings
unxBuild

Next.js Middleware: What Belongs In It and What Will Bite You

Sean

Platform Writer

Aug 13, 2026
9 min read

Next.js middleware is a single file at the root of your project that runs before a request is routed. It is the right place for redirects, rewrites, header and cookie manipulation, locale detection, and optimistic auth checks. It is the wrong place for database queries, session lookups, or anything requiring Node APIs — because it runs on a constrained runtime and sits in front of every matched request, so its latency is your whole site’s latency.

Next.js Middleware: What Belongs In It and What Will Bite You

The API is small. The judgement calls — what to put in it, and what the matcher should cover — are what determine whether it helps or quietly costs you.

Table of contents

The file and the matcher

// middleware.ts at the project root (or src/)
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

export function middleware(request: NextRequest) {
  return NextResponse.redirect(new URL('/home', request.url));
}

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

One middleware file per project. The matcher decides which requests it sees, and getting it right is the single highest-impact thing here — without it, middleware runs on every request including static assets, which is wasted latency on every image.

export const config = {
  matcher: [
    // Everything except static files, images, and metadata routes
    '/((?!api|_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|webp)$).*)',
  ],
};

Matchers must be statically analysable — they are evaluated at build time, so they cannot be built from variables at runtime.

Recent Next.js versions have been moving this file convention toward proxy.ts with the same shape. Check which convention your version documents; the concepts below are unchanged either way.

The four things it returns

// 1. Carry on to the route
return NextResponse.next();

// 2. Redirect -- the URL changes in the browser
return NextResponse.redirect(new URL('/login', request.url));

// 3. Rewrite -- the URL stays, different content is served
return NextResponse.rewrite(new URL('/maintenance', request.url));

// 4. Respond directly, without touching the route
return new NextResponse('Forbidden', { status: 403 });

Redirect versus rewrite is the distinction people get wrong. A redirect is visible: the browser makes a second request and the address bar changes. A rewrite is invisible: same URL, different content. Use rewrite for A/B tests, multi-tenant routing, and maintenance pages; use redirect when the canonical location genuinely changed.

Always construct URLs with new URL(path, request.url). A bare string is treated as relative in ways that break behind a proxy or on a non-root deployment.

Cookies and headers

export function middleware(request: NextRequest) {
  // Read
  const theme = request.cookies.get('theme')?.value;

  // Add a request header the route handler will see
  const headers = new Headers(request.headers);
  headers.set('x-request-id', crypto.randomUUID());

  const response = NextResponse.next({ request: { headers } });

  // Set a response cookie and header
  response.cookies.set('theme', theme ?? 'light', {
    httpOnly: true,
    secure: true,
    sameSite: 'lax',
    path: '/',
  });
  response.headers.set('x-content-type-options', 'nosniff');

  return response;
}

Note the two distinct things: headers passed into NextResponse.next({ request: { headers } }) reach your route handler, while headers set on the returned response reach the browser. Confusing them produces a header that exists in exactly the wrong place.

crypto.randomUUID() is available; require('crypto') is not. That distinction — Web APIs yes, Node APIs no — is the whole shape of the runtime constraint.

Auth in middleware: optimistic only

This is the most important judgement in the article, and the Next.js documentation is explicit about it: middleware should perform optimistic checks, not authorisation.

import { jwtVerify } from 'jose';

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

  if (!token) {
    const url = new URL('/login', request.url);
    url.searchParams.set('from', request.nextUrl.pathname);
    return NextResponse.redirect(url);
  }

  try {
    // Stateless signature check -- no database, no network
    await jwtVerify(token, new TextEncoder().encode(process.env.JWT_SECRET!));
  } catch {
    return NextResponse.redirect(new URL('/login', request.url));
  }

  return NextResponse.next();
}

The reason is latency. Middleware runs before every matched request. A database round trip there adds its cost to every page load, including ones that did not need it. Verifying a signature is microseconds; a session lookup is milliseconds, on every request, forever.

So middleware answers “does this request carry something that looks like a valid session?” and redirects if not. The real authorisation check — is this user allowed to see this resource — belongs in the page, the layout, or the data access layer, where you have the full runtime and the specific resource in hand.

Treating middleware as your security boundary is the mistake. It is a fast filter that improves the common case, not the thing standing between a user and someone else’s data.

The runtime constraints

Middleware runs on a lightweight runtime rather than full Node.js, which is what makes it fast to start and limited in what it can do.

  • Availablefetch, URL, Headers, Request, Response, crypto.subtle, TextEncoder, atob/btoa.
  • Not availablefs, net, child_process, most native database drivers, anything calling into Node bindings.
  • Size limited — the bundle has a cap. A heavy dependency imported here fails to build.
  • No response body streaming from the origin — middleware acts on the request and the response envelope, not the page content.

The practical consequence is library choice. jose works for JWTs where jsonwebtoken does not. Database clients that speak HTTP work; ones using TCP sockets do not.

Newer Next.js versions allow opting middleware into the Node runtime, which removes the API restrictions. It does not remove the latency argument — middleware still runs on every matched request, so putting a database call there is a performance decision regardless of whether it is now possible.

Debugging, and the deployment reality

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

console.log in middleware appears in the server output locally and in your platform’s runtime logs in production. It does not appear in the browser console, which confuses people the first time.

The most common production surprise is a matcher that is broader than intended — middleware running on every static asset, adding latency to every image. Log the pathname temporarily and check what is actually matching.

The other is environment variables. Middleware needs its secrets at runtime, and a JWT_SECRET that exists locally but not in the deployment turns every request into a redirect to the login page. Environment variables configured per service, with runtime logs showing the middleware’s own output next to the failing requests, is what makes that a two-minute diagnosis — which is how a Node service deployed from a repository on RunxBuild is set up.

How this fits the rest of the stack

Middleware is for redirects, rewrites, headers, cookies, locale detection, and optimistic auth. Scope the matcher tightly so it does not run on static assets. Verify JWT signatures rather than looking up sessions, and put real authorisation in the page or data layer where you have the resource in hand. Build URLs with new URL(path, request.url), and remember that request headers and response headers go to different places.

When it misbehaves in production it is usually a matcher that is too broad or a missing environment variable. If you are working out what a Next.js service and its database cost to run, the RunxBuild hosting calculator lists them as separate line items.

Useful related references:

FAQ

What is Next.js middleware used for?

Running code before a request is routed — redirects, rewrites, setting request and response headers, reading and writing cookies, locale detection, and quick optimistic auth checks. It sits in front of every matched request, so it suits work that is fast and applies broadly.

Can I query a database in Next.js middleware?

You should not, even where the runtime now permits it. Middleware runs on every matched request, so a database round trip there adds its latency to every page load. Verify a JWT signature instead, and do the real authorisation check in the page or data access layer.

What is the difference between redirect and rewrite in middleware?

A redirect sends the browser to a new URL, so the address bar changes and a second request is made. A rewrite serves different content at the same URL, invisibly to the user. Use rewrite for A/B tests, multi-tenant routing, and maintenance pages; use redirect when the canonical location actually changed.

Why does my middleware run on images and static files?

The matcher is too broad. Without a config.matcher, middleware runs on every request. Use a negative lookahead pattern that excludes _next/static, _next/image, favicon.ico, and common image extensions so it only runs where it is needed.

Is Next.js middleware a security boundary?

No. It is a fast filter that catches the obvious case of a missing or malformed session. Real authorisation — checking whether this specific user may access this specific resource — belongs in the page, layout, or data access layer, where you have the full runtime and the resource itself.

#nextjs#middleware#edge runtime#authentication#react