Next.js middleware is the worst-documented feature in the framework. The official docs show you a hello-world example, ship it, and discover in production that JWT verification fails, your users hit redirect loops, and half your database library doesn't work.
I've shipped middleware-based auth on three production apps. Here's the setup I use, after all the bugs.
The mental model
Middleware runs on every request before the route handler. It runs on the Edge Runtime by default, which is a restricted sandbox, no Node built-ins like crypto or fs. It's fast (cold start in milliseconds) but limiting.
// middleware.ts (in the root of your project, next to app/)
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
export function middleware(request: NextRequest) {
// Runs on every matched request
return NextResponse.next()
}
export const config = {
matcher: ['/dashboard/:path*', '/admin/:path*'],
}The matcher is critical. Without it, middleware runs on every request, including images, CSS, and API routes.
The auth flow
The pattern: check for a session cookie, verify the JWT, redirect to /login if invalid.
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import { jwtVerify } from 'jose'
const PUBLIC_PATHS = ['/login', '/register', '/forgot-password']
const secret = new TextEncoder().encode(process.env.JWT_SECRET)
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl
// Skip public paths
if (PUBLIC_PATHS.some((p) => pathname.startsWith(p))) {
return NextResponse.next()
}
// Read the session cookie
const token = request.cookies.get('session')?.value
if (!token) {
return redirectToLogin(request)
}
try {
// Verify the JWT
const { payload } = await jwtVerify(token, secret)
// Optionally: check payload.exp, payload.role, etc.
return NextResponse.next()
} catch {
// Token is invalid or expired
return redirectToLogin(request)
}
}
function redirectToLogin(request: NextRequest) {
const loginUrl = new URL('/login', request.url)
loginUrl.searchParams.set('redirect', request.nextUrl.pathname)
return NextResponse.redirect(loginUrl)
}
export const config = {
matcher: [
'/((?!api|_next/static|_next/image|favicon.ico|login|register|forgot-password).*)',
],
}Three things to notice:
joseinstead ofjsonwebtoken.joseworks on Edge.jsonwebtokendoesn't.- The matcher excludes static assets and public pages. Without this, middleware runs on every image and creates a redirect loop on
/login. - We store the user info in the JWT. We can't hit the database in middleware.
Why jose and not jsonwebtoken
jsonwebtoken is the most popular JWT library for Node. It also doesn't work in middleware.
The reason: middleware runs on the Edge Runtime, which doesn't have Node's crypto module. jsonwebtoken requires it. You'll see errors like Module not found: Can't resolve 'crypto'.
jose is a pure-JS JWT library that works on Edge. It's the officially recommended library.
import { jwtVerify, SignJWT } from 'jose'
// Verify in middleware
const { payload } = await jwtVerify(token, secret)
// Sign in your login route
const token = await new SignJWT({ userId: '123', role: 'admin' })
.setProtectedHeader({ alg: 'HS256' })
.setExpirationTime('7d')
.sign(secret)The redirect loop trap
This is the bug everyone hits:
- User visits
/dashboardwithout a session - Middleware redirects to
/login - Middleware runs again on
/login - Middleware redirects to
/login(because no session) - Browser:
ERR_TOO_MANY_REDIRECTS
The fix is the matcher regex. Two options:
Option 1: Exclude public paths in the matcher (preferred)
export const config = {
matcher: [
// Match everything EXCEPT these paths
'/((?!api|_next/static|_next/image|favicon.ico|login|register).*)',
],
}Option 2: Check inside middleware
const PUBLIC_PATHS = ['/login', '/register', '/forgot-password']
if (PUBLIC_PATHS.some((p) => pathname.startsWith(p))) {
return NextResponse.next()
}Both work. Option 1 is faster because middleware never runs at all on public paths.
Don't hit the database from middleware
Middleware runs on every page load. If you query the database on every request, you add 50-200ms to every page.
Instead, put everything you need in the JWT:
// Login route: sign a JWT with the role
const token = await new SignJWT({
userId: user.id,
role: user.role,
organizationId: user.organizationId,
})
.setProtectedHeader({ alg: 'HS256' })
.setExpirationTime('7d')
.sign(secret)When the user's role changes (admin demoted to user), you have to reissue the JWT. The pattern: store a tokenVersion on the user, include it in the JWT, and bump it on role changes. Middleware checks if the token's version matches the latest version in a fast Edge-compatible store (Upstash Redis, for example).
For most apps, just expire tokens frequently (24 hours) and accept a small window of stale permissions.
The matcher config I ship
export const config = {
matcher: [
// Run on all paths EXCEPT:
// - API routes (handled separately)
// - Next internals (_next/static, _next/image)
// - Static files in /public (by extension)
// - Public auth pages
'/((?!api|_next/static|_next/image|favicon.ico|robots.txt|sitemap.xml|login|register|forgot-password|reset-password).*)',
],
}This runs middleware on real pages, skips everything that doesn't need auth.
When middleware isn't the right tool
Middleware is good for:
- Auth checks (does the user have a valid session?)
- A/B testing (which variant to show?)
- Geolocation redirects
- Feature flag checks (cached in a cookie)
Middleware is bad for:
- Database queries (latency, Edge limits)
- Complex authorization logic (do it in Server Components or Server Actions)
- Session refresh (do it in a route handler, not middleware)
- Anything that requires Node built-ins
If your middleware needs the database, it's probably the wrong layer. Move the check into a Server Component or a layout.
The patterns I avoid
Avoid: export const config = { matcher: ['/'] }, runs middleware on every route, including static files. Always scope the matcher.
Avoid: await db.user.findUnique(...) in middleware, too slow, often doesn't work on Edge. Put the data in the JWT.
Avoid: setting cookies in middleware for auth state, use a route handler to set the cookie on login, then middleware only reads.
Avoid: using middleware for everything, it's an auth and routing layer. Not a place for business logic.
Next.js middleware is an auth layer, not a database layer. Verify the JWT, redirect, and get out. Everything else belongs in a Server Component or a route handler.
Want auth done right in Next.js?
I set up Next.js middleware auth that doesn't loop, doesn't break the Edge Runtime, and doesn't ship your secrets to the browser. Let's talk.