Kazi Rahamatullah
Kazi Rahamatullah
AboutProjectsBlogContact
UsesBooks
ResumeView CV
Kazi Rahamatullah

© Copyright 2026 Kazi Rahamatullah

AboutProjectsBlogBooksUses
Twitter/XGitHubProduct HuntCodeSandbox
Back to Blog
Next.jsMiddlewareProxyNext-AuthAuthenticationApp Router

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.

Jul 6, 202618 min read
Next.js Middleware

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.

plaintext
                    Next.js Middleware
                            │
    ┌──────────┬────────────┼────────────┬──────────┬──────────┐
    │          │            │            │          │          │
Fundamentals  Core Use   Implementation  Config &   NextAuth   Routing
              Cases                    Matchers    Integration  Logic
                                                      │
                                                 API Security

What this guide covers:

BranchTopics
1. FundamentalsRequest/response interception, client-server gap, Edge vs Node runtime
2. Core use casesProtected routes, authentication, authorization headers, i18n, redirection
3. Implementation detailsmiddleware.ts / proxy.ts, exported function, NextRequest, NextResponse
4. Configuration & matchersconfig object, URL patterns, regex, infinite loops, exit criteria
5. Next-Auth integrationauth.config.ts split, no DB in middleware, session retrieval, credentials
6. Routing logicPublic routes vs private routes with real path examples
7. API securityRoute handler protection, manual auth(), 401, DB in handlers only

Note

This guide uses Next.js 16 proxy.ts syntax first. Legacy middleware.ts (Next.js 15 and earlier) is noted where the API differs. Run npx @next/codemod@latest middleware-to-proxy . to migrate existing projects.

Quick index

#Section
1Fundamentals
2Core use cases
3Implementation details
4Configuration and matchers
5Next-Auth middleware integration
6Routing logic
7API security
8Production 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.

plaintext
Browser  →  GET /dashboard
              ↓
         middleware / proxy.ts   ← reads cookies, pathname
              ↓
         app/dashboard/page.tsx  ← only runs if middleware allows
What you can read on the requestExample
URL pathname & search paramsrequest.nextUrl.pathname
Cookiesrequest.cookies.get('session')
Headersrequest.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.

tsx
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.

ProblemWithout middlewareWith middleware
Unauthenticated user hits /dashboardPage flashes, then client redirectsRedirect before HTML is sent
Wrong locale URLClient-side locale detection flickersRedirect to /en/... immediately
Missing auth header on APIHandler runs, then returns 401Block at edge with 401

Note

Middleware is not a replacement for Server Component auth() checks or route handler validation. It closes the flash-of-unauthorized-content gap on the client — real security still belongs in handlers and authorize callbacks.

Edge Runtime vs Node.js (proxy.ts in v16)

VersionFileDefault runtime
Next.js 15 and earliermiddleware.tsEdge Runtime
Next.js 16+proxy.tsNode.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.

Tip

Keep middleware/proxy logic lean regardless of runtime. Auth redirects, locale prefixes, and header injection — not database queries or heavy business logic.

Back to index


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.

tsx
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.

tsx
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 } })
}
HeaderMiddleware use
AuthorizationBearer token validation at the edge
x-forwarded-hostMulti-tenant routing behind load balancers
Accept-LanguageLocale detection for i18n

Internationalization

Redirect users to a locale-prefixed path based on cookie or Accept-Language header.

tsx
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.

tsx
// 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))
}

Performance

Pick the narrowest tool for each job. Use middleware for cross-cutting redirects and headers. Use redirect() inside Server Components for page-level logic that already has session context from auth().

Back to index


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 versionFile nameExport name
16+proxy.tsproxy
15 and earliermiddleware.tsmiddleware
bash
npx @next/codemod@latest middleware-to-proxy .

Exported middleware function

Next.js 16 accepts a named or default export — one function per file.

tsx
// 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.

tsx
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

MethodEffectURL bar changes?
NextResponse.next()Continue to matched routeNo
NextResponse.redirect(url)Send browser to new URLYes
NextResponse.rewrite(url)Forward internallyNo
NextResponse.json(data, { status })Return immediatelyNo
tsx
// 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 } })

Warning

Redirect changes the browser URL. Rewrite forwards internally — the user still sees the original URL. Use rewrite for multi-tenant backend routing; use redirect for auth gates.

Back to index


4. Configuration and matchers

Config object

Export a config object alongside your middleware function to control which paths trigger execution.

tsx
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.

PatternMatches
/dashboardExact path only
/dashboard/:path*/dashboard, /dashboard/settings, /dashboard/billing/invoices
/api/:path*All API routes
/checkout/:path*Checkout and sub-routes
tsx
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.

tsx
export const config = {
  matcher: ['/((?!_next/static|_next/image|favicon.ico|api/auth|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)'],
}
Excluded pathWhy
_next/staticBuilt JS/CSS bundles
_next/imageImage optimization
api/authNext-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.

tsx
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 criteriaAction
Path is public (/, /login, /register, /products)NextResponse.next()
Path is Next-Auth callback (/api/auth/*)NextResponse.next()
Static asset or _next internalExcluded via matcher regex
Session exists on auth pageRedirect to /dashboard
No session on private routeRedirect to /login
All other casesNextResponse.next()

Warning

Always exclude /api/auth/callback/* from redirect logic. OAuth flows break silently when Google or GitHub callbacks get redirected to /login. Test social login end-to-end after adding middleware.

Tip

Prefer a narrow matcher over a catch-all matcher with heavy in-function filtering. Fewer invocations = faster cold starts and easier debugging.

Back to index


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.

ts
// 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
ts
// 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

tsx
// ❌ Breaks — imports mongoose, prisma, bcrypt
import { auth } from '@/auth'
 
export async function proxy(request: NextRequest) {
  const session = await auth() // pulls DB adapters
}
tsx
// ✅ 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).*)'],
}

Warning

Never import prisma, mongoose, or bcrypt in proxy.ts / middleware.ts. Full session validation with database lookups belongs in route handlers via auth().

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.

ts
// 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.

Note

Credential login itself runs in authorize with MongoDB — that is in auth.ts, not middleware. Middleware only checks whether a valid session cookie exists after login. See Register User To MongoDB With Next-Auth V5 for the full credentials + MongoDB flow.

Tip

The authorized callback in auth.config.ts is the declarative way to express "is this path allowed?" — reusable across middleware and reusable if you call auth() in route handlers with the same config.

Back to index


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.

RoutePurpose
/loginCredential and OAuth sign-in
/registerUser registration
/productsPublic product listing
/api/auth/callback/googleGoogle OAuth callback
/api/auth/callback/githubGitHub OAuth callback
/api/auth/*All Next-Auth internal routes
tsx
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.

RoutePurpose
/dashboardUser dashboard
/checkoutCheckout flow
/dashboard/billingProtected sub-route
/dashboard/settingsProtected sub-route
/api/meAuthenticated API endpoint
tsx
const privatePrefixes = ['/dashboard', '/checkout', '/api/me']
 
function isPrivate(pathname: string) {
  return privatePrefixes.some((prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`))
}

Complete routing middleware

tsx
// 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)$).*)'],
}

Note

Middleware checks cookie presence for fast UX redirects. Route handlers and Server Components must still call auth() to verify the session is valid — not expired or tampered with.

Back to index


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.

plaintext
Client  →  GET /api/me
              ↓
         middleware (cookie exists?)
              ↓
         app/api/me/route.ts (auth() — full session check)
              ↓
         200 with user data  OR  401

Proxy-level API guard

tsx
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

tsx
// 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,
  })
}
tsx
// 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

StatusWhen to return
401No session or invalid token — "who are you?"
403Valid session but insufficient permissions — "you cannot do this"

Warning

Middleware returning redirect() on API fetch() calls behaves differently from page navigations. For JSON API routes, return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) — not a redirect to /login.

Direct database connection — handlers only

LayerDB access?Why
Middleware / proxy.tsNoKeep edge gate fast; avoid connection pool exhaustion
Route handlersYesFull Node.js runtime, auth() + Prisma/Mongoose
Server ComponentsYesauth() + data fetching in same request

Performance

Middleware runs on every matched request. A single cookie check costs microseconds; a MongoDB findOne on every page view does not scale. Defer DB lookups to route handlers and Server Components.

Back to index


8. Production checklist & troubleshooting

Before deploying

StepAction
1Use proxy.ts on Next.js 16 — migrate middleware.ts with codemod
2One interception file only — root or src/
3Explicit matcher — exclude _next, static assets, api/auth
4Public vs private routes defined and tested
5OAuth callbacks excluded from redirect logic
6Split Next-Auth into auth.config.ts + auth.ts
7API routes return 401 JSON — not redirects
8auth() called in protected route handlers

Common errors

ErrorCauseFix
Infinite redirect loop/login matched and redirectedAdd public route exit criteria
OAuth callback fails/api/auth/callback/* redirectedExclude api/auth from matcher or logic
Module not found in proxyImported Prisma/Mongoose in middlewareUse auth.config.ts split only
API returns HTML login pageUsed redirect() instead of 401 JSONReturn NextResponse.json for APIs
Middleware never runsMatcher too narrowVerify path matches config.matcher

next.config.js flag renames (v16)

LegacyNext.js 16
skipMiddlewareUrlNormalizeskipProxyUrlNormalize
experimental.middlewarePrefetchexperimental.proxyPrefetch

Interview Answer

"What is Next.js Middleware and when do you use it?"

Middleware intercepts requests before pages or API routes run. Use it for auth redirects, locale routing, security headers, and blocking unauthenticated API calls. In Next.js 16 it is renamed to Proxy (proxy.ts). Keep it thin — no database calls. Split Next-Auth into auth.config.ts for middleware and auth.ts for full DB-backed auth. Always verify sessions again in route handlers with auth() and return 401 for APIs.

Back to index


Summary

Mind map branchKey takeaway
FundamentalsRequest/response interceptor; bridges client-server gap; lean runtime
Core use casesProtected routes, auth, headers, i18n, redirects
Implementationproxy.ts, NextRequest, NextResponse
Config & matchersconfig.matcher, regex exclusions, exit criteria, no infinite loops
Next-Auth integrationauth.config.ts split, session in authorized, credentials JWT
Routing logicPublic: login, register, products, auth callbacks — Private: dashboard, checkout
API securityMiddleware cookie check + handler auth() + 401 JSON

Related reads: Next-Auth V5 || Google & GitHub, Credential Provider, Register User To MongoDB, and Next.js Caching & Rendering.

Share this article

XLinkedInFacebook
Kazi Rahamatullah

Written by

Kazi Rahamatullah

FullStack Developer

X / TwitterGitHubLinkedIn

Subscribe to my newsletter

Stay up to date and get notified when I share new contents.

No spam ever, unsubscribe anytime