Next.js Middleware: Auth, Redirects, A/B Testing — The Real Use Cases
Next.js Middleware runs at the edge before your page loads. Here's how to actually use it for auth, redirects, and A/B testing — with real code examples.
What Middleware Actually Is (And Isn't)
Next.js Middleware is a single middleware.ts file you drop at the root of your project. It runs on the Edge Runtime — not Node.js, not a Lambda — which means it executes at Vercel's (or Cloudflare's, or wherever you deploy) CDN nodes, before your page or API route even wakes up. That's the whole deal. The latency budget is tiny, the environment is intentionally constrained, and it's exactly that constraint that makes it so sharp for a specific class of problems.
What you can't do: no fs, no native Node modules, no Prisma Client, no heavy ORM queries. In practice, you're working with the Web Fetch API, cookies, headers, and URL manipulation. That sounds limiting until you realise that 80% of "I need to intercept this request" problems are actually about routing decisions based on a session token or a cookie value — and for that, Middleware is perfect.
Worth noting: Middleware replaced _middleware.ts back in Next.js 12.2. If you're reading docs from 2022 that reference per-folder middleware files, that API is gone. As of Next.js 14 and 15, there's one file, one export, one config matcher, and you're done.
Honestly, most apps I've seen add Middleware only once they've already tried solving auth redirects in getServerSideProps and felt the pain. Redirecting in getServerSideProps means your server-rendered page starts rendering before you abort — with Middleware, the redirect fires in under 1ms at the edge, before a single byte of HTML is produced.
Setting Up Middleware: The Basics
Create middleware.ts in your project root (same level as app/ or pages/). The file exports a default function and optionally a config object that controls which paths it intercepts. Without config.matcher, the middleware runs on every single request — including _next/static, image optimisation routes, and all the other noise you don't want to touch.
Here's the minimal boilerplate you'll actually reuse:
``ts
// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
export function middleware(request: NextRequest) {
// Your logic here
return NextResponse.next()
}
export const config = {
matcher: [
/*
* Match all request paths EXCEPT:
* - _next/static (static files)
* - _next/image (image optimisation)
* - favicon.ico
* - public files
*/
'/((?!_next/static|_next/image|favicon.ico|public/).*)',
],
}
``
The matcher supports plain strings, regex-style glob patterns, and arrays of either. One more thing — NextResponse.next() passes through unchanged, NextResponse.redirect(url) sends a 307 by default, and NextResponse.rewrite(url) changes the URL internally without the browser knowing. You'll use all three.
Quick aside: if you're on the App Router and using layout.tsx for auth checks, you're already running that logic on every navigation. Middleware does the same job earlier and cheaper. It won't replace a proper server-side session check in your data-fetching layer, but it absolutely handles the "don't even render the page" redirect.
Auth Protection: The Most Common Use Case
The pattern is simple: read a cookie or a header, validate it enough to decide if the user is logged in, redirect to /login if not. You're not doing a full DB lookup — you're checking a signed JWT or a session token value. Keep it light.
Here's a real pattern using a signed cookie (e.g., NextAuth.js next-auth.session-token):
``ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
const PROTECTED_PATHS = ['/dashboard', '/settings', '/api/private']
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl
const isProtected = PROTECTED_PATHS.some(p => pathname.startsWith(p))
if (!isProtected) return NextResponse.next()
const token =
request.cookies.get('next-auth.session-token')?.value ??
request.cookies.get('__Secure-next-auth.session-token')?.value
if (!token) {
const loginUrl = new URL('/login', request.url)
loginUrl.searchParams.set('callbackUrl', pathname)
return NextResponse.redirect(loginUrl)
}
return NextResponse.next()
}
export const config = {
matcher: ['/dashboard/:path*', '/settings/:path*', '/api/private/:path*'],
}
``
Notice __Secure-next-auth.session-token — NextAuth prefixes the cookie with __Secure- in HTTPS environments. If you don't check both, your auth protection silently breaks in production while working perfectly in localhost:3000. That's a nasty bug to debug at 2am.
Look, if you need full JWT verification at the edge, the jose library works in the Edge Runtime (no Node crypto required). jsonwebtoken does not — it'll fail at build time. Use jose's jwtVerify with your secret, and you can skip the round-trip entirely.
In practice, this approach with a matcher-limited middleware adds roughly 2–5ms to your first-byte time on Vercel's edge network. Compared to a getServerSideProps redirect that costs a full serverless invocation, that's a meaningful win.
Redirects: Replacing next.config.js Rewrites
You can define static redirects in next.config.js under the redirects key. But the second you need any runtime logic — country detection, cookie values, query params — you're reaching for Middleware. Static config can't branch.
Here's a country-based redirect pattern using the x-vercel-ip-country header Vercel injects automatically:
``ts
export function middleware(request: NextRequest) {
const country = request.headers.get('x-vercel-ip-country') ?? 'US'
const { pathname } = request.nextUrl
// Only redirect the root — don't loop
if (pathname === '/' && country === 'FR') {
return NextResponse.redirect(new URL('/fr', request.url))
}
return NextResponse.next()
}
``
That pathname === '/' guard is important. Without it you'd redirect /fr back to itself forever if you're not careful with your matcher. The other common trap: forgetting that NextResponse.redirect sends a 307 Temporary Redirect by default. If you want a permanent 301 for SEO-sensitive routes (like when you've renamed a slug), pass { status: 301 } as the second argument.
URL-based rewrites are even more interesting. Say you're running /blog/[slug] but you want /posts/[slug] to still work without a visible URL change:
``ts
if (pathname.startsWith('/posts/')) {
const newPath = pathname.replace('/posts/', '/blog/')
return NextResponse.rewrite(new URL(newPath, request.url))
}
``
That said, don't replicate your entire next.config.js redirects array inside Middleware. Static redirects defined in config are optimised and cached differently — use Middleware only when you need logic that depends on request data.
A/B Testing at the Edge — No Flicker, No Client JS
Client-side A/B testing with something like Google Optimize or a random Math.random() on mount always has the same problem: flash of the wrong variant. The user sees variant A for 200ms before JavaScript swaps in variant B. It's ugly and your conversion data is contaminated by that flicker.
Middleware solves this cleanly. You assign the variant server-side (edge-side, really) before any HTML renders, store it in a cookie, and rewrite the URL to the correct variant page — all invisible to the browser:
``ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
const VARIANT_COOKIE = 'ab-homepage'
const VARIANTS = ['a', 'b']
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl
if (pathname !== '/') return NextResponse.next()
// Read existing assignment or pick a new one
let variant = request.cookies.get(VARIANT_COOKIE)?.value
if (!variant || !VARIANTS.includes(variant)) {
variant = Math.random() < 0.5 ? 'a' : 'b'
}
// Rewrite to /homepage-a or /homepage-b without changing the URL
const rewriteUrl = new URL(/homepage-${variant}, request.url)
const response = NextResponse.rewrite(rewriteUrl)
// Persist the variant so the same user always gets the same experience
response.cookies.set(VARIANT_COOKIE, variant, {
maxAge: 60 * 60 * 24 * 30, // 30 days
httpOnly: true,
})
return response
}
``
You'd create app/homepage-a/page.tsx and app/homepage-b/page.tsx as your two variants. They share components, layouts, whatever — the only thing that differs is what gets rendered for that route. Your analytics then track which cookie value each session has, and you split the conversion data by variant.
One more thing — this pattern also works for feature flags. Replace the random coin flip with a user cohort lookup (read from a cookie you set at login, or from a header your CDN enriches) and you've got a zero-latency feature flagging system with no third-party SDK required. That's a huge win for projects where adding PostHog or LaunchDarkly feels like overkill.
Worth noting: if you're building a polished UI for the pages behind your experiments — variant B with a completely new layout, or a glassmorphism-heavy redesign — Empire UI's component library has ready-to-go blocks that snap into the App Router without a rewrite. Why rebuild a hero section from scratch when you're just testing copy?
Middleware Chaining, Debugging, and Edge Cases
Next.js only supports one middleware.ts file. There's no native middleware chaining like Express has. But you can compose your own:
``ts
type MiddlewareFn = (req: NextRequest) => NextResponse | null
function chain(fns: MiddlewareFn[]) {
return (request: NextRequest) => {
for (const fn of fns) {
const result = fn(request)
if (result) return result
}
return NextResponse.next()
}
}
export const middleware = chain([authMiddleware, abTestMiddleware, redirectMiddleware])
``
Each function returns a NextResponse to short-circuit, or null to pass to the next. It's 20 lines and it works. Honestly, more sophisticated patterns exist (the next-middleware-chain package on npm, for instance) but for most apps this manual approach is readable enough that you don't want the abstraction.
Debugging is the awkward part. console.log in Middleware goes to your deployment logs, not the browser console. Locally, you'll see it in terminal. On Vercel, check the Functions tab — but remember, Middleware logs show up under a different section than serverless function logs, and it took me way too long to find that in the Vercel dashboard circa 2025.
There's one sharp edge with the App Router: Middleware runs before Server Components render, but it doesn't have access to React context, cookies() from next/headers, or anything from the React tree. Those only exist in the RSC execution context. If you need data from Middleware in your layout, pass it via a request header that you set in Middleware and read in the layout's headers() call — that's the official bridge between the two worlds.
``ts
// In middleware.ts
const response = NextResponse.next()
response.headers.set('x-user-role', role)
return response
// In layout.tsx (server component)
import { headers } from 'next/headers'
const role = headers().get('x-user-role')
``
If you're working on a project where the visual UI layer needs to match user roles or experiment variants — say, showing a glassmorphism dashboard to your Pro tier and a simpler layout to free users — the Middleware-to-header bridge is exactly how you thread that signal through without a context provider.
When Not to Use Middleware
Middleware runs on every matched request. If your matcher is too broad and you're doing non-trivial work inside it, you'll add latency across your entire site. A token decode that takes 8ms doesn't sound like much until it's blocking every page load for every user.
Don't use Middleware for database queries. The Edge Runtime's constraints aren't just about API availability — connection pooling models that work in serverless functions (like Prisma with @prisma/client/edge + PgBouncer) add cold-start overhead that compounds badly at the edge. Push database checks into your route handlers or Server Components where you can pool connections properly.
Rate limiting is tempting to implement in Middleware, but the stateless nature of each Middleware invocation means you can't maintain counters without an external store (Upstash Redis is the go-to). It's doable, but it's not free. Same goes for logging — streaming every request through Middleware to a logging service adds latency that probably isn't worth it for most apps.
If you're looking for related patterns on how route structure and loading states interact in the App Router, the Next.js App Router guide covers the broader architecture that Middleware slots into. Middleware is one piece — understanding how it interacts with layouts, loading segments, and streaming is what makes it really click.
FAQ
No. The Edge Runtime doesn't support Node.js modules, which rules out Prisma Client and most ORMs. Use lightweight JWT verification with jose instead, or call a dedicated Edge-compatible API route for anything that needs a DB lookup.
Yes, but it runs in a simulated edge environment, not a real one. Behavior is very close to production, but headers injected by your CDN (like Vercel's x-vercel-ip-country) won't be there — you'll need to fake them locally for geo-based logic.
Export a config object with a matcher array from your middleware.ts. You can use path patterns like '/dashboard/:path*' or regex-style exclusions. Without it, Middleware runs on every request including static assets, which you almost never want.
Yes, with a caveat: you can read a role from a signed JWT cookie and redirect at the edge, but complex permission checks that need a DB lookup should happen in your Server Component or API layer after the initial routing decision.