middleware in Next.js
Next.js middleware is a TypeScript function in middleware.ts that runs on the Edge Network before your routes, letting you intercept, inspect, and redirect requests without touching a single page component.
Theory
TL;DR
- Think of it as an airport security checkpoint: every request passes through once, and you decide whether it gets redirected, rewritten, or passed through.
- Runs on Edge Runtime (V8 isolates, not Node.js), so no cold starts and no access to
fsor Prisma. - Without
config.matcher, it fires on every request including static files. Always add a matcher. - Best fit: auth guards, i18n redirects, CORS headers. Bad fit: direct database queries.
- One
middleware.tsper project. No native chaining like Express.
Quick example
// middleware.ts at project root
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
export function middleware(request: NextRequest) {
if (request.nextUrl.pathname.startsWith('/dashboard')) {
const token = request.cookies.get('auth')?.value
if (!token) return NextResponse.redirect(new URL('/login', request.url))
}
return NextResponse.next()
}
// Fires only on /dashboard and sub-paths; skips /api, /_next/static
export const config = { matcher: '/dashboard/:path*' }Any unauthenticated request to /dashboard/settings hits the middleware, gets no cookie, and lands on /login. Authenticated requests pass through untouched.
Key difference from Express
Next.js middleware runs on the Edge Network before the request reaches your serverless functions or pages. Express middleware runs per-instance in Node.js after the server is already up. The practical difference: Next.js middleware is globally distributed and stateless, while Express middleware can hold state and use any Node.js module. That is a real tradeoff you need to know before an interview.
When to use
- Auth checks on protected routes (
/app/*,/admin/*) - middleware - Locale detection and URL rewriting - middleware
- Adding security headers (CSP, CORS) - middleware
- A/B test bucket assignment via cookies - middleware
- Rate limiting
/api/*with an external store like Upstash - middleware - Complex permission logic that needs the database - server component or route handler
- Paths with more than 50% of your total traffic - reconsider, Edge has request limits
How it works internally
Next.js bundles middleware.ts into a WebAssembly-like module for Edge Runtime (V8 isolates). On each request: the matcher regex checks the path in O(1); if it matches, the runtime invokes your function with a NextRequest object built on the Web Fetch API; your function returns a NextResponse that signals redirect, rewrite, or pass-through to the router. No Node.js process is involved at any point.
NextRequest gives you cookies, headers, nextUrl, and geo. NextResponse lets you call redirect(), rewrite(), or next() with optional header mutations. You cannot mutate the original request directly because it is immutable.
Common mistakes
1. Using export default instead of a named export.
// Wrong - produces no middleware at runtime, no build error either
export default function middleware(request) { ... }
// Fix
export function middleware(request: NextRequest) { ... }Next.js requires the named export. The default export compiles fine and silently does nothing.
2. Skipping config.matcher.
Without a matcher, middleware runs on every request including /_next/static, /_next/image, and favicon.ico. On Vercel's free tier this burns your middleware quota fast. Add a matcher.
3. await fetch without a timeout.
Edge Runtime has a 30-second wall clock limit. A hanging external fetch stalls the entire request. Fix:
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 5000)
const res = await fetch('https://auth-service.example.com/verify', {
signal: controller.signal
})
clearTimeout(timeout)4. Trying to use Node.js modules.
fs, crypto (Node flavor), Prisma, bcrypt - none of these work in Edge Runtime. The build fails with a module resolution error. Use Edge-compatible alternatives: @vercel/kv, @upstash/redis, Web Crypto API.
5. Mutating the request object directly.
NextRequest is immutable. Setting headers on it throws a TypeError. To pass data downstream, use response headers, cookies, or rewrite the URL with search params.
Real-world usage
- Vercel Dashboard: auth middleware redirects unauthenticated users from
/team/* - Clerk / NextAuth.js: session cookie checks on all protected routes
- next-intl: rewrites
/to/enor/debased on theAccept-Languageheader - Stripe integrations: rate limiting
/api/stripe/*via Upstash sliding window - Create T3 App: middleware for admin auth and CSRF header injection
Follow-up questions
Q: How does middleware differ from App Router route handlers?
A: Middleware runs before routing on the Edge with no React context. Route handlers run per-route on the server with full Node.js access. For auth: middleware is the first-pass check, route handlers handle the business logic.
Q: Can middleware access a database directly?
A: No. Edge Runtime is stateless with no persistent connections. For state, use external services: Upstash Redis, PlanetScale serverless driver, or Vercel KV. Middleware checks a token; server components do the permission query.
Q: How do you handle multiple middlewares?
A: There is one middleware.ts per Next.js project. Compose logic with sequential if blocks or use a router library like hono for mini-routes inside the single file. There is no true chaining like Express app.use().
Q: What is the matcher syntax for skipping static files?
A: '/((?!api|_next/static|_next/image|favicon.ico).*)'. The negative lookahead skips those prefixes. Without it, middleware runs on every static asset request.
Q: What happens when middleware needs to both redirect and set a cookie?
A: Only the first returned NextResponse wins. Once you return NextResponse.redirect(...), the function exits. If you need a cookie on a redirect, create the redirect response, call .cookies.set() on it, then return it.
Q: What changed in Next.js 15 for middleware?
A: Turbopack support landed and async matcher config became experimental. The core API (NextRequest, NextResponse, config.matcher) stayed the same.
Examples
Auth guard with redirect URL preservation
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl
const token = request.cookies.get('session')?.value
if (pathname.startsWith('/app') && !token) {
// Preserve the original URL so login can redirect back
const loginUrl = new URL('/login', request.url)
loginUrl.searchParams.set('from', pathname)
return NextResponse.redirect(loginUrl)
}
return NextResponse.next()
}
export const config = { matcher: '/app/:path*' }
// /app/settings with no cookie → /login?from=/app/settings
// /app/settings with valid cookie → proceeds normallyAfter login, your login page reads ?from and redirects the user back to where they were. A small thing that makes auth feel polished in production.
Locale detection and URL rewriting
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
const locales = ['en', 'de', 'fr']
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl
const hasLocale = locales.some(
locale => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`
)
if (hasLocale) return NextResponse.next()
const acceptLanguage = request.headers.get('accept-language') ?? ''
const preferred = acceptLanguage.split(',')[0].slice(0, 2)
const locale = locales.includes(preferred) ? preferred : 'en'
// Rewrite preserves the URL in the browser; redirect would change it
return NextResponse.rewrite(new URL(`/${locale}${pathname}`, request.url))
}
export const config = {
matcher: '/((?!api|_next/static|_next/image|favicon.ico).*)'
}
// GET / with Accept-Language: de,en → renders /de/ without changing browser URLThe difference between redirect and rewrite matters here. rewrite changes what renders; redirect changes the URL the user sees. For i18n, rewrite is almost always the right call.
Rate limiting API routes with Upstash Redis
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import { Ratelimit } from '@upstash/ratelimit'
import { Redis } from '@upstash/redis'
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(10, '1 m'), // 10 requests per minute per IP
})
export async function middleware(request: NextRequest) {
if (request.nextUrl.pathname.startsWith('/api')) {
const ip = request.headers.get('x-forwarded-for') ?? 'anonymous'
const { success } = await ratelimit.limit(ip)
if (!success) {
return new NextResponse('Too Many Requests', {
status: 429,
headers: { 'Retry-After': '60' }
})
}
const response = NextResponse.next()
response.headers.set('Access-Control-Allow-Origin', '*')
response.headers.set('Access-Control-Allow-Methods', 'GET,POST,PUT,DELETE')
return response
}
return NextResponse.next()
}
export const config = { matcher: '/api/:path*' }
// Each IP gets 10 requests per minute on /api routes
// Over the limit → 429 with Retry-After headerOne thing to watch: if UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN are missing from your environment, Redis.fromEnv() throws at cold start and takes down your entire middleware layer. Add those env vars before deploying.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.