DevLift
Back to Blog

Next.js Middleware (Now Proxy): Route Protection, Redirects, and Edge Guards Done Right

One file to guard every route — plus the Next.js 16 rename that moves it off the Edge runtime, the request-vs-response header trap that leaks user IDs to the browser, and the CVE that explains why this can never be your only auth layer.

Admin
August 3, 20268 min read5 views

Next.js Middleware (Now Proxy): Route Protection, Redirects, and Edge Guards Done Right

Your dashboard is supposed to be protected. So you add an auth check in the layout — redirect('/sign-in') if there's no session. Works fine locally. But then you add a new route group. Another page. A settings page. Each one gets its own layout, and suddenly you're copying the same session check everywhere. You miss one. A user bookmarks a deep link, the cookie expires, and they land on a blank dashboard that throws because user is null.

Middleware is the fix. One file, one place, all routes covered before a single React component ever renders.

First: On Next.js 16 It Isn't Called Middleware Anymore

Before any of the patterns, the file has a new name. Next.js 16 deprecated the middleware convention and renamed it to proxy. Keep a middleware.ts and 16.1.6 still runs it, but it prints:

⚠ The "middleware" file convention is deprecated. Please use "proxy" instead.
  Learn more: https://nextjs.org/docs/messages/middleware-to-proxy

Ship both files and it's a hard error, not a warning — the build refuses and tells you to keep only proxy. There's a codemod that renames the file and the exported function:

npx @next/codemod@canary middleware-to-proxy .

The rename is not cosmetic, and this is the part that changes how you write the file: Proxy defaults to the Node.js runtime. Middleware defaulted to the Edge Runtime; Node was opt-in from 15.2 (experimental) and stable from 15.5; in 16 it's the default, and the runtime segment config isn't even allowed in a proxy file — setting it throws.

That flips a claim you'll find in every middleware article written before 16. Here's the same file under each convention on 16.1.6, importing node:fs and node:crypto:

proxy.ts       → 200, x-runtime-probe: node-22.22.3, readFileSync worked
middleware.ts  → 500, Error: Failed to load external module node:fs:
                      TypeError: Native module not found: node:fs

So jsonwebtoken and bcrypt are not off the table anymore. They were off the table for Edge middleware, and everything below still applies if you're on 15 or you deploy the proxy to a CDN edge, but on a stock 16 Node deployment your proxy has the whole standard library.

Everything else about the file is unchanged. It sits at the root of your project (or src/ if you use that layout) and runs on every request that matches your config, before the request hits any page, layout, or API route.

project/
├── src/
│   ├── proxy.ts        ← runs before everything (was middleware.ts)
│   ├── app/
│   │   ├── layout.tsx
│   │   └── ...

The function signature is simple:

import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
 
export function proxy(request: NextRequest) {
  // inspect the request, return a response
  return NextResponse.next(); // pass it through
}
 
export const config = {
  matcher: ['/dashboard/:path*', '/settings/:path*'],
};
💡

The rest of this article says "middleware" where it means the concept and proxy where it means the file, because that's the vocabulary you'll be reading in issues and Stack Overflow answers for the next couple of years. If you're on 15, mentally substitute middleware.ts / export function middleware throughout — the APIs are identical.

There are four things you can return:

  • NextResponse.next() — pass through, optionally with modified headers
  • NextResponse.redirect(url) — 307 redirect, changes the browser URL
  • NextResponse.rewrite(url) — serve different content without changing the URL
  • NextResponse.json(data) — return a JSON response directly (useful for API guards)

The Problem: Auth Checks Scattered Across Layouts

Before middleware, the common approach was auth checks in each layout:

// app/(dashboard)/layout.tsx — the scatter pattern
import { auth } from '@/lib/auth';
import { redirect } from 'next/navigation';
 
export default async function DashboardLayout({ children }) {
  const session = await auth();
  if (!session) redirect('/sign-in');
  return <>{children}</>;
}
 
// app/(settings)/layout.tsx — same check, different file
export default async function SettingsLayout({ children }) {
  const session = await auth();  // copy-paste
  if (!session) redirect('/sign-in');
  return <>{children}</>;
}

This has a few problems. First, it's duplicated. Second, it fires a database round-trip (or at minimum a session cookie parse) at render time, after the request has already reached your server. Third, if you forget a layout, a route slips through unguarded.

Middleware centralizes this. One check, before any rendering.

The Pattern: JWT Verification Before the Render

The examples below use jose rather than jsonwebtoken. On a Node-runtime proxy either works; jose is still the better default because it runs unchanged if you ever deploy the proxy to an edge platform, it's Web Crypto based, and it's what Auth.js uses internally. Treat it as portability insurance rather than a hard constraint.

⚠️

If you're on Next.js 15 or earlier, or you've deliberately put this file on an edge platform, it is a hard constraint: no fs, no path, no Node crypto, and most npm packages that assume Node will fail to load. The 500 in the runtime probe above is exactly that failure.

// src/proxy.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { jwtVerify } from 'jose';
 
const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET!);
 
const PROTECTED_ROUTES = ['/dashboard', '/settings', '/billing', '/learn'];
const AUTH_ROUTES = ['/sign-in', '/sign-up'];
 
function isProtected(pathname: string) {
  return PROTECTED_ROUTES.some((route) => pathname.startsWith(route));
}
 
function isAuthRoute(pathname: string) {
  return AUTH_ROUTES.some((route) => pathname.startsWith(route));
}
 
export async function proxy(request: NextRequest) {
  const { pathname } = request.nextUrl;
  const token = request.cookies.get('session')?.value;
 
  // Verify the JWT if present
  let payload: { sub: string; role: string } | null = null;
  if (token) {
    try {
      const { payload: p } = await jwtVerify(token, JWT_SECRET);
      payload = p as { sub: string; role: string };
    } catch {
      // Token is invalid or expired — treat as unauthenticated
    }
  }
 
  const isAuthenticated = payload !== null;
 
  // Redirect authenticated users away from auth pages
  if (isAuthRoute(pathname) && isAuthenticated) {
    return NextResponse.redirect(new URL('/dashboard', request.url));
  }
 
  // Guard protected routes
  if (isProtected(pathname) && !isAuthenticated) {
    const signInUrl = new URL('/sign-in', request.url);
    signInUrl.searchParams.set('callbackUrl', pathname);
    return NextResponse.redirect(signInUrl);
  }
 
  return NextResponse.next();
}
 
export const config = {
  matcher: [
    /*
     * Match everything EXCEPT:
     * - _next/static (build output)
     * - _next/image (image optimization)
     * - favicon.ico
     * - public folder assets
     * - api/webhooks (handled internally, no auth needed)
     */
    '/((?!_next/static|_next/image|favicon.ico|.*\\.(?:png|jpg|svg|ico)|api/webhooks).*)',
  ],
};

The callbackUrl parameter is a nice touch — after the user signs in, you redirect them back to where they were trying to go.

Rendering diagram...

The Matcher: Be Precise

The matcher config controls which paths run your middleware. Bad matchers are a common source of bugs and unnecessary overhead.

The negative lookahead pattern above is the most robust approach — you list what to exclude rather than what to include. This way, new routes are automatically protected without updating the matcher.

But for simpler cases, the positive matcher is more readable:

export const config = {
  matcher: [
    '/dashboard/:path*',
    '/settings/:path*',
    '/billing/:path*',
    '/learn/:path*',
    '/admin/:path*',
  ],
};
⚠️

Matcher values must be constants — no variables, no dynamic logic. They're statically analyzed at build time. Dynamic values will silently be ignored.

The :path* syntax matters. Without the *, /dashboard/:path only matches one level deep: /dashboard/overview but not /dashboard/billing/invoices. The asterisk makes it recursive.

Forwarding User Info to Downstream Components

A useful pattern is attaching user info to request headers so your Server Components don't need to re-verify the JWT. There are two ways to write this and only one of them is right:

// ❌ Sets a RESPONSE header. This one goes to the browser.
const response = NextResponse.next();
response.headers.set('x-user-id', payload.sub);
return response;
 
// ✅ Sets a REQUEST header. Visible to your Server Components, not to the client.
const requestHeaders = new Headers(request.headers);
requestHeaders.set('x-user-id', payload.sub);
requestHeaders.set('x-user-role', payload.role);
return NextResponse.next({ request: { headers: requestHeaders } });

The trap is that the wrong version appears to work. Running the first form on 16.1.6, headers().get('x-user-id') inside the Server Component returns the value you set — so the feature you were building works and you move on. But curl -D- on the same request shows why you shouldn't:

HTTP/1.1 200 OK
x-user-id: usr_article_pattern
x-user-role: admin

You've published the signed-in user's ID and role to every response, visible in devtools and cacheable by anything in between. The Next.js docs are blunt about the distinction: NextResponse.next({ request: { headers } }) makes headers available upstream, NextResponse.next({ headers }) makes them available to clients.

Then in a Server Component:

// app/(dashboard)/layout.tsx
import { headers } from 'next/headers';
 
export default async function DashboardLayout({ children }) {
  const headersList = await headers();
  const userId = headersList.get('x-user-id');
  const role = headersList.get('x-user-role');
  // No database call needed for basic auth context
  return <SidebarLayout userId={userId} role={role}>{children}</SidebarLayout>;
}

Variations

Role-Based Access Control

Add a second guard after the auth check:

// Admin routes need the 'admin' role
if (pathname.startsWith('/admin') && payload?.role !== 'admin') {
  return NextResponse.redirect(new URL('/dashboard', request.url));
}

Locale Detection and i18n Routing

Middleware is the standard place for locale negotiation — read the Accept-Language header, check for a locale cookie, then rewrite:

export function proxy(request: NextRequest) {
  const { pathname } = request.nextUrl;
 
  // Already has a locale prefix
  if (/^\/(en|fr|de)/.test(pathname)) {
    return NextResponse.next();
  }
 
  // Detect from Accept-Language header
  const locale = request.headers.get('accept-language')?.split(',')[0].slice(0, 2) ?? 'en';
  const supported = ['en', 'fr', 'de'];
  const resolved = supported.includes(locale) ? locale : 'en';
 
  return NextResponse.redirect(new URL(`/${resolved}${pathname}`, request.url));
}

A/B Testing

Rewrites let you serve different page variants without changing the URL. Assign a variant once, store it in a cookie, read it on every request:

export function proxy(request: NextRequest) {
  const variant = request.cookies.get('ab-pricing')?.value
    ?? (Math.random() < 0.5 ? 'control' : 'treatment');
 
  const response = NextResponse.rewrite(
    new URL(`/pricing-${variant}`, request.url),
  );
 
  // Set the cookie if it wasn't already present
  if (!request.cookies.get('ab-pricing')) {
    response.cookies.set('ab-pricing', variant, { maxAge: 60 * 60 * 24 * 30 });
  }
 
  return response;
}

The user always sees /pricing in their URL bar. You serve /pricing-control or /pricing-treatment internally.

Geolocation-Based Routing

If you learned this pattern as request.geo?.country, that property is gone. geo and ip were removed from NextRequest in Next.js 15 — they're not in the 16.1.6 type declarations at all, so it's a compile error rather than a silent undefined. Geo data now comes from your platform's own helper, or from the header it sets:

import { geolocation } from '@vercel/functions';
 
export function proxy(request: NextRequest) {
  // On Vercel: @vercel/functions. Elsewhere, read your platform's header —
  // e.g. Cloudflare sets `cf-ipcountry`.
  const { country = 'US' } = geolocation(request);
 
  // Redirect to country-specific pricing
  if (request.nextUrl.pathname === '/pricing') {
    if (country === 'IN') {
      return NextResponse.rewrite(new URL('/pricing/india', request.url));
    }
    if (['DE', 'FR', 'NL'].includes(country)) {
      return NextResponse.rewrite(new URL('/pricing/eu', request.url));
    }
  }
 
  return NextResponse.next();
}
💡

Geo data is a platform feature, not a Next.js one. On a plain self-hosted Node server nothing populates it and you'll always fall through to your default — so treat the default as a real branch, not a formality.

The CVE That Rewrote the Rule

In March 2025, a critical vulnerability (CVE-2025-29927) was disclosed: an attacker could bypass middleware entirely by sending the header x-middleware-subrequest: middleware in the request. On unpatched Next.js versions, this caused the middleware to skip itself — protected routes became public.

It was patched in 15.2.3, 14.2.25, 13.5.9, and 12.3.5. If you're on an older version, update immediately.

But the bigger lesson is architectural. Middleware is a guard at the door, not the safe inside the building. It's optimized for the happy path — fast rejection of clearly unauthenticated requests. A sufficiently motivated attacker (or a future vulnerability) can get past the door. Your data must be protected inside the room too.

The right model:

Rendering diagram...

Middleware rejects the obvious cases at the edge, before a server is touched. Server Components and API routes verify again before touching sensitive data. This is defense in depth — the middleware breach in 2025 would've been a non-event for apps built this way.

When NOT to Use Middleware

Don't put database queries in middleware. Every request goes through it. A DB call per request is a performance footgun at scale. Pass a JWT with the user ID, fetch full user data lazily in the page that actually needs it.

Don't use middleware as your only auth layer. See above. Always re-verify in Server Components before loading sensitive data.

Don't do heavy computation. Middleware runs before every matching request. Parsing a large body, running ML inference, or doing expensive string manipulation will slow down your entire app.

Don't use it for things that need Node.js APIs — if you're on Edge. This was an absolute rule for middleware.ts and it's no longer one for a 16 proxy.ts, which runs on Node by default. But "you can" isn't "you should": the docs describe Proxy as something that may be deployed to a CDN in front of your app, and explicitly warn against relying on shared modules or globals inside it. Reading files or spinning up a database client here is still the wrong place for it, whether or not the runtime allows it.

Avoid infinite redirect loops. If your matcher matches /sign-in and you redirect unauthenticated users to /sign-in, you'll loop forever. Always check that the redirect target is excluded from the matcher.

The Clean Setup

Here's the pattern that works for most production apps:

// src/proxy.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { jwtVerify } from 'jose';
 
const secret = new TextEncoder().encode(process.env.JWT_SECRET!);
 
const isProtected = (pathname: string) =>
  pathname.startsWith('/dashboard') || pathname.startsWith('/admin');
 
export async function proxy(request: NextRequest) {
  const { pathname } = request.nextUrl;
  const token = request.cookies.get('session')?.value;
 
  let userId: string | null = null;
  let role: string | null = null;
  let tokenWasBad = false;
 
  if (token) {
    try {
      const { payload } = await jwtVerify(token, secret);
      userId = payload.sub ?? null;
      role = (payload.role as string) ?? null;
    } catch {
      // Expired or tampered token. Note what happened, but don't redirect yet —
      // see the note below on why this matters.
      tokenWasBad = true;
    }
  }
 
  // Only a bad token on a page that actually needs auth is worth interrupting for.
  if (tokenWasBad && isProtected(pathname)) {
    const response = NextResponse.redirect(new URL('/sign-in', request.url));
    response.cookies.delete('session');
    return response;
  }
 
  // Bounce unauthenticated users off protected paths
  if (!userId && pathname.startsWith('/dashboard')) {
    const url = new URL('/sign-in', request.url);
    url.searchParams.set('callbackUrl', pathname);
    return NextResponse.redirect(url);
  }
 
  // Bounce authed users away from auth pages
  if (userId && (pathname === '/sign-in' || pathname === '/sign-up')) {
    return NextResponse.redirect(new URL('/dashboard', request.url));
  }
 
  // Admin guard
  if (pathname.startsWith('/admin') && role !== 'admin') {
    return NextResponse.redirect(new URL('/dashboard', request.url));
  }
 
  // Forward user context to server components via headers
  const requestHeaders = new Headers(request.headers);
  if (userId) requestHeaders.set('x-user-id', userId);
  if (role) requestHeaders.set('x-user-role', role);
 
  return NextResponse.next({ request: { headers: requestHeaders } });
}
 
export const config = {
  matcher: [
    '/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)|api/webhooks).*)',
  ],
};

This covers auth, role-based guards, and user context propagation in about 60 lines. It rejects invalid tokens by clearing the cookie rather than just redirecting, and it passes user info downstream on the request headers, so nothing leaks to the browser.

The tokenWasBad flag deserves a note, because the obvious version of this file gets it wrong. If you redirect to /sign-in the moment jwtVerify throws — inside the catch, before checking the path — then with a wide matcher like the one above, a visitor whose cookie expired last week gets bounced off your marketing homepage. Your own "avoid infinite redirect loops" rule is the same bug wearing a different hat: decide whether the path needs auth before you decide to interrupt the request.

One last thing: the x-user-id header trick only works if you trust your own proxy. In a multi-layer deployment (e.g. behind an API gateway that adds its own headers), strip and re-set these headers in the proxy to prevent header injection from upstream. Cloning request.headers as above does not do that for you — it copies whatever arrived, including an attacker-supplied x-user-id, so delete it before you set it if the edge in front of you isn't yours.

Where to go from here: revalidatePath and revalidateTag can't be called from a proxy file at all — they only work in Server Functions and Route Handlers — so the interesting cases are what happens when you rewrite to a cached route. That, and the updateTag primitive Next 16 added next to revalidateTag, are where the non-obvious behaviour lives.

Comments (0)

No comments yet. Be the first to share your thoughts!

Related Articles

Server Actions collapse form handling into a single server function—no API route, no manual fetch, no useState for loading. Here's the pattern that replaces 80% of your mutation boilerplate.
AdminAugust 3, 20267 min read
Parallel routes let a single layout render multiple independent pages at once. Combine them with intercepting routes and you get URL-aware modals with zero hacks.
AdminAugust 3, 20267 min read
The EventEmitter pattern lets components in the same process react to the same event without being directly coupled — no message broker needed.
AdminAugust 3, 20266 min read