Next.js Middleware
Complete Next.js Middleware guide — request/response interception, protected routes, matchers, Next-Auth v5 integration, public vs private routing, API security, i18n, and proxy.ts migration for Next.js 16.

Introduction
Next.js Middleware sits between the browser and your application — intercepting every matching
request before a page or API route runs. In Next.js 16, the same feature is renamed to Proxy
(proxy.ts), but the mental model is identical: a network-layer gate for auth, redirects, headers, and locale routing.
Next.js Middleware
│
┌──────────┬────────────┼────────────┬──────────┬──────────┐
│ │ │ │ │ │
Fundamentals Core Use Implementation Config & NextAuth Routing
Cases Matchers Integration Logic
│
API SecurityWhat this guide covers:
| Branch | Topics |
|---|---|
| 1. Fundamentals | Request/response interception, client-server gap, Edge vs Node runtime |
| 2. Core use cases | Protected routes, authentication, authorization headers, i18n, redirection |
| 3. Implementation details | middleware.ts / proxy.ts, exported function, NextRequest, NextResponse |
| 4. Configuration & matchers | config object, URL patterns, regex, infinite loops, exit criteria |
| 5. Next-Auth integration | auth.config.ts split, no DB in middleware, session retrieval, credentials |
| 6. Routing logic | Public routes vs private routes with real path examples |
| 7. API security | Route handler protection, manual auth(), 401, DB in handlers only |
Quick index
| # | Section |
|---|---|
| 1 | Fundamentals |
| 2 | Core use cases |
| 3 | Implementation details |
| 4 | Configuration and matchers |
| 5 | Next-Auth middleware integration |
| 6 | Routing logic |
| 7 | API security |
| 8 | Production checklist & troubleshooting |
1. Fundamentals
Middleware is a request and response interceptor — the same pattern CDNs, nginx, and API gateways use. It runs before your React tree, route handlers, or Server Components execute.
Request interceptor
Every matching incoming request passes through your middleware function first. You can read the URL, cookies, and headers — then decide whether to continue, redirect, rewrite, or block.
Browser → GET /dashboard
↓
middleware / proxy.ts ← reads cookies, pathname
↓
app/dashboard/page.tsx ← only runs if middleware allows| What you can read on the request | Example |
|---|---|
| URL pathname & search params | request.nextUrl.pathname |
| Cookies | request.cookies.get('session') |
| Headers | request.headers.get('authorization') |
| Geo (platform-dependent) | request.geo?.country |
Response interceptor
Middleware can also shape the outgoing response — attach security headers, set cookies, or return early without hitting the route at all.
export function proxy(request: NextRequest) {
const response = NextResponse.next()
// Response interceptor — headers added before route renders
response.headers.set('X-Frame-Options', 'DENY')
response.headers.set('X-Content-Type-Options', 'nosniff')
return response
}Return NextResponse.json(), NextResponse.redirect(), or NextResponse.rewrite() to short-circuit the pipeline entirely — the matched route never runs.
Client-server logic gap
Middleware bridges logic that must run before the server renders — without waiting for React hydration or a client-side useEffect.
| Problem | Without middleware | With middleware |
|---|---|---|
Unauthenticated user hits /dashboard | Page flashes, then client redirects | Redirect before HTML is sent |
| Wrong locale URL | Client-side locale detection flickers | Redirect to /en/... immediately |
| Missing auth header on API | Handler runs, then returns 401 | Block at edge with 401 |
Edge Runtime vs Node.js (proxy.ts in v16)
| Version | File | Default runtime |
|---|---|---|
| Next.js 15 and earlier | middleware.ts | Edge Runtime |
| Next.js 16+ | proxy.ts | Node.js |
Edge Runtime has constraints: no Node.js fs, limited npm packages, no direct mongoose/prisma imports. That is why Next-Auth integration requires splitting config — covered in section 5.
2. Core use cases
Protected routes
Gate routes that require a session before any page code runs. Middleware checks for a session cookie or JWT, then redirects unauthenticated users to /login.
if (!session && pathname.startsWith('/dashboard')) {
return NextResponse.redirect(new URL('/login', request.url))
}Authentication
Verify who the user is — session cookie present, JWT valid, or Next-Auth authorized callback returns true. Authentication answers: "Is this person logged in?"
Authorization headers
Read and forward Authorization headers for API routes or attach tenant context before the handler runs.
export function proxy(request: NextRequest) {
const authHeader = request.headers.get('authorization')
if (request.nextUrl.pathname.startsWith('/api/internal') && !authHeader?.startsWith('Bearer ')) {
return NextResponse.json({ error: 'Missing bearer token' }, { status: 401 })
}
const requestHeaders = new Headers(request.headers)
requestHeaders.set('x-request-id', crypto.randomUUID())
return NextResponse.next({ request: { headers: requestHeaders } })
}| Header | Middleware use |
|---|---|
Authorization | Bearer token validation at the edge |
x-forwarded-host | Multi-tenant routing behind load balancers |
Accept-Language | Locale detection for i18n |
Internationalization
Redirect users to a locale-prefixed path based on cookie or Accept-Language header.
const locales = ['en', 'bn', 'de']
const defaultLocale = 'en'
export function proxy(request: NextRequest) {
const { pathname } = request.nextUrl
const hasLocale = locales.some((locale) => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`)
if (hasLocale) return NextResponse.next()
const cookieLocale = request.cookies.get('locale')?.value
const acceptLanguage = request.headers.get('accept-language')?.split(',')[0]?.split('-')[0]
const locale = locales.includes(cookieLocale ?? '')
? cookieLocale!
: locales.includes(acceptLanguage ?? '')
? acceptLanguage!
: defaultLocale
return NextResponse.redirect(new URL(`/${locale}${pathname}`, request.url))
}
export const config = {
matcher: ['/((?!_next|api|favicon.ico|.*\\..*).*)'],
}Redirection
Server-side redirects before the route renders — login gates, post-auth redirects, maintenance mode.
// Logged-in users should not see login/register
if (session && (pathname === '/login' || pathname === '/register')) {
return NextResponse.redirect(new URL('/dashboard', request.url))
}
// Maintenance mode
if (process.env.MAINTENANCE_MODE === 'true' && pathname !== '/maintenance') {
return NextResponse.redirect(new URL('/maintenance', request.url))
}3. Implementation details
middleware.ts / proxy.ts file placement
Create the file at the project root or inside src/ — same level as app/. Only one interception file per project.
| Next.js version | File name | Export name |
|---|---|---|
| 16+ | proxy.ts | proxy |
| 15 and earlier | middleware.ts | middleware |
npx @next/codemod@latest middleware-to-proxy .Exported middleware function
Next.js 16 accepts a named or default export — one function per file.
// proxy.ts — Next.js 16+ (recommended)
import type { NextRequest } from 'next/server'
import { NextResponse } from 'next/server'
export function proxy(request: NextRequest) {
return NextResponse.next()
}
// Legacy — Next.js 15
// export function middleware(request: NextRequest) { ... }Request object argument — NextRequest
NextRequest extends the Web Request API with Next.js-specific helpers.
export function proxy(request: NextRequest) {
const { pathname, searchParams } = request.nextUrl
const callbackUrl = searchParams.get('callbackUrl')
const session = request.cookies.get('authjs.session-token')?.value
const userAgent = request.headers.get('user-agent')
console.log({ pathname, callbackUrl, hasSession: !!session })
return NextResponse.next()
}NextResponse utility
| Method | Effect | URL bar changes? |
|---|---|---|
NextResponse.next() | Continue to matched route | No |
NextResponse.redirect(url) | Send browser to new URL | Yes |
NextResponse.rewrite(url) | Forward internally | No |
NextResponse.json(data, { status }) | Return immediately | No |
// Redirect — login gate
return NextResponse.redirect(new URL('/login', request.url))
// Rewrite — reverse proxy to backend (URL bar unchanged)
return NextResponse.rewrite(new URL('/api/internal-handler', request.url))
// Block — API unauthorized
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
// Continue with modified request headers
return NextResponse.next({ request: { headers: requestHeaders } })4. Configuration and matchers
Config object
Export a config object alongside your middleware function to control which paths trigger execution.
export const config = {
matcher: ['/dashboard/:path*', '/api/:path*'],
}Without an explicit matcher on older setups, middleware may run on every request including static assets — slow and error-prone.
URL pattern matching
Next.js matcher supports path patterns with dynamic segments.
| Pattern | Matches |
|---|---|
/dashboard | Exact path only |
/dashboard/:path* | /dashboard, /dashboard/settings, /dashboard/billing/invoices |
/api/:path* | All API routes |
/checkout/:path* | Checkout and sub-routes |
export const config = {
matcher: ['/dashboard/:path*', '/checkout/:path*', '/api/protected/:path*'],
}Regular expression filtering
Exclude _next internals, static files, and images with a negative lookahead regex.
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico|api/auth|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)'],
}| Excluded path | Why |
|---|---|
_next/static | Built JS/CSS bundles |
_next/image | Image optimization |
api/auth | Next-Auth OAuth callbacks must not be redirected |
*.png, *.svg, etc. | Static assets |
Infinite loop prevention
Classic bug: middleware redirects unauthenticated users to /login, but /login is also matched — middleware runs again and loops forever.
const publicRoutes = ['/login', '/register', '/']
function isPublic(pathname: string) {
return publicRoutes.some((route) => pathname === route || pathname.startsWith(`${route}/`))
}
// Only redirect when NOT on a public route
if (!session && !isPublic(pathname)) {
return NextResponse.redirect(new URL('/login', request.url))
}Exit criteria logic
Define explicit conditions for when middleware should pass through without further checks.
| Exit criteria | Action |
|---|---|
Path is public (/, /login, /register, /products) | NextResponse.next() |
Path is Next-Auth callback (/api/auth/*) | NextResponse.next() |
Static asset or _next internal | Excluded via matcher regex |
| Session exists on auth page | Redirect to /dashboard |
| No session on private route | Redirect to /login |
| All other cases | NextResponse.next() |
5. Next-Auth middleware integration
Next-Auth v5 pairs with middleware — but importing the full auth.ts inside proxy.ts often breaks because it pulls MongoDB, Prisma, or bcrypt dependencies.
auth.config.ts split
Separate middleware-safe session logic from database-heavy auth setup.
// auth.config.ts — no database imports
import type { NextAuthConfig } from 'next-auth'
export const authConfig = {
pages: {
signIn: '/login',
},
providers: [], // Providers defined in auth.ts only
callbacks: {
authorized({ auth, request: { nextUrl } }) {
const isLoggedIn = !!auth?.user
const { pathname } = nextUrl
const isProtected = pathname.startsWith('/dashboard') || pathname.startsWith('/checkout')
if (isProtected) return isLoggedIn
return true
},
},
} satisfies NextAuthConfig// auth.ts — full Next-Auth with MongoDB (route handlers only)
import NextAuth from 'next-auth'
import { authConfig } from './auth.config'
import Credentials from 'next-auth/providers/credentials'
import Google from 'next-auth/providers/google'
import bcrypt from 'bcryptjs'
import { getUserByEmail } from '@/lib/queries/users'
export const { handlers, auth, signIn, signOut } = NextAuth({
...authConfig,
providers: [
Google({/* ... */}),
Credentials({
async authorize(credentials) {
const user = await getUserByEmail(String(credentials?.email))
if (!user) return null
const match = await bcrypt.compare(String(credentials?.password), user.password)
if (!match) return null
return { id: user._id.toString(), name: user.name, email: user.email }
},
}),
],
session: { strategy: 'jwt' },
})Removing DB dependencies from middleware
// ❌ Breaks — imports mongoose, prisma, bcrypt
import { auth } from '@/auth'
export async function proxy(request: NextRequest) {
const session = await auth() // pulls DB adapters
}// ✅ Safe — only auth.config.ts
import NextAuth from 'next-auth'
import { authConfig } from './auth.config'
export default NextAuth(authConfig).auth
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
}Session retrieval in middleware
Next-Auth's middleware wrapper reads the encrypted session cookie and populates auth in the authorized callback — no manual cookie parsing required.
// auth.config.ts
callbacks: {
authorized({ auth, request: { nextUrl } }) {
const isLoggedIn = !!auth?.user
// auth.user.name, auth.user.email available when logged in
// ...
},
}Credential provider support
Credentials login uses JWT sessions (not database sessions). The middleware reads the JWT cookie set by Next-Auth after signIn('credentials') succeeds — same authorized callback works for both OAuth and credentials.
6. Routing logic
Define public and private route lists explicitly — middleware enforces them in one place instead of per-page redirect() calls.
Public routes
Routes any visitor can access without a session.
| Route | Purpose |
|---|---|
/login | Credential and OAuth sign-in |
/register | User registration |
/products | Public product listing |
/api/auth/callback/google | Google OAuth callback |
/api/auth/callback/github | GitHub OAuth callback |
/api/auth/* | All Next-Auth internal routes |
const publicRoutes = ['/', '/login', '/register', '/products']
function isPublic(pathname: string) {
if (pathname.startsWith('/api/auth')) return true
return publicRoutes.some((route) => pathname === route || pathname.startsWith(`${route}/`))
}Private routes
Routes that require an authenticated session.
| Route | Purpose |
|---|---|
/dashboard | User dashboard |
/checkout | Checkout flow |
/dashboard/billing | Protected sub-route |
/dashboard/settings | Protected sub-route |
/api/me | Authenticated API endpoint |
const privatePrefixes = ['/dashboard', '/checkout', '/api/me']
function isPrivate(pathname: string) {
return privatePrefixes.some((prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`))
}Complete routing middleware
// proxy.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
const publicRoutes = ['/', '/login', '/register', '/products']
const authRoutes = ['/login', '/register']
function isPublic(pathname: string) {
if (pathname.startsWith('/api/auth')) return true
return publicRoutes.some((route) => pathname === route || pathname.startsWith(`${route}/`))
}
function isAuthRoute(pathname: string) {
return authRoutes.some((route) => pathname === route)
}
export function proxy(request: NextRequest) {
const { pathname } = request.nextUrl
const session = request.cookies.get('authjs.session-token')?.value
// Logged-in users skip login/register
if (session && isAuthRoute(pathname)) {
return NextResponse.redirect(new URL('/dashboard', request.url))
}
// Unauthenticated users blocked from private routes
if (!session && !isPublic(pathname)) {
const loginUrl = new URL('/login', request.url)
loginUrl.searchParams.set('callbackUrl', pathname)
return NextResponse.redirect(loginUrl)
}
return NextResponse.next()
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)'],
}7. API security
Middleware can block /api/* requests early, but route handlers must also verify sessions — defense in depth.
Route handler protection
Two layers: middleware blocks obvious unauthenticated requests; the handler validates the full session.
Client → GET /api/me
↓
middleware (cookie exists?)
↓
app/api/me/route.ts (auth() — full session check)
↓
200 with user data OR 401Proxy-level API guard
export function proxy(request: NextRequest) {
const { pathname } = request.nextUrl
if (pathname.startsWith('/api/')) {
// Always allow Next-Auth and health checks
if (pathname.startsWith('/api/auth') || pathname === '/api/health') {
return NextResponse.next()
}
const session = request.cookies.get('authjs.session-token')?.value
if (!session) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
}
return NextResponse.next()
}Manual session checks in route handlers
// app/api/me/route.ts
import { auth } from '@/auth'
import { NextResponse } from 'next/server'
export async function GET() {
const session = await auth()
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
return NextResponse.json({
id: session.user.id,
name: session.user.name,
email: session.user.email,
})
}// app/api/orders/route.ts — role-based check
import { auth } from '@/auth'
import { NextResponse } from 'next/server'
export async function GET() {
const session = await auth()
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Direct database connection — allowed in route handlers, NOT middleware
const orders = await db.order.findMany({ where: { userId: session.user.id } })
return NextResponse.json(orders)
}401 Unauthorized status
| Status | When to return |
|---|---|
| 401 | No session or invalid token — "who are you?" |
| 403 | Valid session but insufficient permissions — "you cannot do this" |
Direct database connection — handlers only
| Layer | DB access? | Why |
|---|---|---|
| Middleware / proxy.ts | No | Keep edge gate fast; avoid connection pool exhaustion |
| Route handlers | Yes | Full Node.js runtime, auth() + Prisma/Mongoose |
| Server Components | Yes | auth() + data fetching in same request |
8. Production checklist & troubleshooting
Before deploying
| Step | Action |
|---|---|
| 1 | Use proxy.ts on Next.js 16 — migrate middleware.ts with codemod |
| 2 | One interception file only — root or src/ |
| 3 | Explicit matcher — exclude _next, static assets, api/auth |
| 4 | Public vs private routes defined and tested |
| 5 | OAuth callbacks excluded from redirect logic |
| 6 | Split Next-Auth into auth.config.ts + auth.ts |
| 7 | API routes return 401 JSON — not redirects |
| 8 | auth() called in protected route handlers |
Common errors
| Error | Cause | Fix |
|---|---|---|
| Infinite redirect loop | /login matched and redirected | Add public route exit criteria |
| OAuth callback fails | /api/auth/callback/* redirected | Exclude api/auth from matcher or logic |
Module not found in proxy | Imported Prisma/Mongoose in middleware | Use auth.config.ts split only |
| API returns HTML login page | Used redirect() instead of 401 JSON | Return NextResponse.json for APIs |
| Middleware never runs | Matcher too narrow | Verify path matches config.matcher |
next.config.js flag renames (v16)
| Legacy | Next.js 16 |
|---|---|
skipMiddlewareUrlNormalize | skipProxyUrlNormalize |
experimental.middlewarePrefetch | experimental.proxyPrefetch |
Summary
| Mind map branch | Key takeaway |
|---|---|
| Fundamentals | Request/response interceptor; bridges client-server gap; lean runtime |
| Core use cases | Protected routes, auth, headers, i18n, redirects |
| Implementation | proxy.ts, NextRequest, NextResponse |
| Config & matchers | config.matcher, regex exclusions, exit criteria, no infinite loops |
| Next-Auth integration | auth.config.ts split, session in authorized, credentials JWT |
| Routing logic | Public: login, register, products, auth callbacks — Private: dashboard, checkout |
| API security | Middleware cookie check + handler auth() + 401 JSON |
Related reads: Next-Auth V5 || Google & GitHub, Credential Provider, Register User To MongoDB, and Next.js Caching & Rendering.
Subscribe to my newsletter
Stay up to date and get notified when I share new contents.
No spam ever, unsubscribe anytime