4 min read
Middleware Patterns in Next.js
Middleware runs before any route is rendered. It sits between the incoming request and whatever your app would normally do with it, which makes it useful for things like authentication checks, redirects, and header manipulation. The key thing to understand is where it runs: at the edge, not in Node.js.
That constraint shapes everything about how you use it.
Where Middleware Lives
A single file, middleware.ts, goes at the root of your project (next to package.json, not inside app/). Next.js picks it up automatically. You export a middleware function and, optionally, a config object with a matcher to control which paths it runs on.
// middleware.ts
import { NextRequest, NextResponse } from 'next/server'
export function middleware(request: NextRequest) {
return NextResponse.next()
}
export const config = {
matcher: ['/dashboard/:path*', '/account/:path*'],
}
Without a matcher, middleware runs on every request, including static assets and API routes. That's almost never what you want. Be specific with your matcher patterns from the start.
Auth Redirect Pattern
The most common use case: check whether the user has a session token, and redirect to the login page if they don't.
// middleware.ts
import { NextRequest, NextResponse } from 'next/server'
const protectedRoutes = ['/dashboard', '/account', '/settings']
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl
const isProtected = protectedRoutes.some((route) =>
pathname.startsWith(route)
)
if (!isProtected) {
return NextResponse.next()
}
const token = request.cookies.get('session')?.value
if (!token) {
const loginUrl = new URL('/login', request.url)
loginUrl.searchParams.set('from', pathname)
return NextResponse.redirect(loginUrl)
}
return NextResponse.next()
}
export const config = {
matcher: ['/dashboard/:path*', '/account/:path*', '/settings/:path*'],
}
This reads a cookie, checks it exists, and redirects if it doesn't. Notice there is no token verification here, just presence. Full verification against a JWT or a session store needs to happen somewhere, but middleware is often not the right place for it (more on that below).
What You Can and Can't Do
Middleware runs in the Edge Runtime. That means fast startup and global distribution, but it also means a restricted API surface. You don't have access to Node.js built-ins like fs, crypto (the Node version), or anything that requires native bindings.
In practice, this means:
- No database calls. You can't query Postgres, call Prisma, or hit a Redis instance from middleware. The edge runtime has no database drivers.
- No Node.js APIs.
fs,path,Buffer(partially), and most of the Node standard library are unavailable. - No heavy libraries. Anything that depends on Node.js internals will fail at build time or runtime.
What you can do:
- Read and write cookies and headers
- Redirect and rewrite requests
- Use the Web Crypto API (available in the edge runtime)
- Call external APIs with
fetch
That last point is technically possible, but calling an external endpoint on every request adds latency to every page load. Keep it reserved for situations where you have no other choice.
Keeping It Fast
Middleware runs on every matched request, potentially thousands of times per minute. Any latency you add here multiplies across your entire traffic. A few rules worth following:
Keep the logic synchronous where possible. Reading a cookie is synchronous and instant. Making a fetch call to verify a token remotely is not.
If you need to verify a JWT, do it inline using the Web Crypto API or a lightweight edge-compatible library like jose. That keeps verification fast and local, without a network round trip.
import { jwtVerify } from 'jose'
const secret = new TextEncoder().encode(process.env.JWT_SECRET)
export async function middleware(request: NextRequest) {
const token = request.cookies.get('session')?.value
if (!token) {
return NextResponse.redirect(new URL('/login', request.url))
}
try {
await jwtVerify(token, secret)
return NextResponse.next()
} catch {
return NextResponse.redirect(new URL('/login', request.url))
}
}
This verifies the token at the edge without touching an external service. The jose library is edge-compatible, so this works in middleware without the runtime restrictions biting you.
When to Use Layouts Instead
Middleware is good for coarse-grained checks: is there a token at all, should this path redirect somewhere else, does this request need a header added? It is not good for fine-grained authorization: can this specific user access this specific resource?
That kind of logic belongs in a layout or a page, running on the server with full access to your database and session store.
// app/dashboard/layout.tsx
import { redirect } from 'next/navigation'
import { getSession } from '@/lib/auth'
export default async function DashboardLayout({
children,
}: {
children: React.ReactNode
}) {
const session = await getSession()
if (!session) {
redirect('/login')
}
if (!session.user.hasAccess('dashboard')) {
redirect('/unauthorized')
}
return <div>{children}</div>
}
The layout has access to your full server environment. It can query the database, check permissions, and make decisions based on real data. Middleware can only see what's in the request itself.
A practical split: use middleware to redirect unauthenticated users away from protected paths, and use layouts to enforce permissions once you know who the user is.
The Mental Model
Middleware is a thin guard at the door. It checks for credentials quickly and turns away anyone who shouldn't get in. It doesn't run the full security check itself, it just handles the obvious cases so your app doesn't have to.
Anything beyond basic presence checks or simple redirects, push it into your route handlers, server actions, or layouts where you have the full runtime and can do the job properly.