Middleware
The Gatekeeper That Runs Before Every Request
LinkedIn Hook
"Where do you put logic that needs to run before a page even starts rendering?"
That question catches most Next.js developers off guard. They reach for
useEffect, or a layout component, or maybe an API route -- but the real answer is middleware.Middleware is the invisible gatekeeper of your entire application. It intercepts every request at the edge, before any page component loads, before any layout renders, before any data fetching begins. It can redirect unauthenticated users, rewrite URLs for A/B testing, block traffic from specific regions, and enforce rate limits -- all in under a millisecond.
Yet in interviews, most candidates can't explain where the file goes, how the matcher works, or why middleware runs on the edge runtime instead of Node.js.
In Lesson 5.4, I break down everything you need to know about Next.js middleware -- from file placement to authentication guards to geolocation-based routing. This is the security checkpoint every request passes through.
Read the full lesson -> [link]
#NextJS #Middleware #WebDevelopment #Authentication #EdgeComputing #FrontendDevelopment #InterviewPrep
What You'll Learn
- What middleware is and where it sits in the Next.js request lifecycle
- Exactly where to place
middleware.tsand why the location matters - How the
matcherconfig controls which routes middleware intercepts - How to implement redirects, rewrites, and header modifications
- Building authentication guards that protect entire route groups
- Geolocation-based routing for international applications
- The rate limiting concept and how middleware enables it
- Edge runtime constraints and what you cannot do in middleware
The Airport Security Analogy
Think of your Next.js application as an international airport. Every passenger (HTTP request) arrives and wants to reach their gate (a specific page). But before they get anywhere near the gate, they pass through security screening -- that's middleware.
The security checkpoint doesn't serve food or sell souvenirs (it doesn't render pages). It has one job: inspect and decide. It checks your boarding pass (authentication token), verifies your destination (URL path), and makes a decision:
- Let you through -- the request continues to the page as normal.
- Redirect you -- "Your gate changed. Go to Terminal B instead." The browser navigates to a different URL.
- Rewrite your path -- "You're actually going to Gate 42, but your boarding pass still says Gate 7." The URL stays the same, but the content comes from a different route.
- Block you entirely -- "You can't enter. Go back to the check-in desk." Return a 403 or redirect to a login page.
And critically, this checkpoint runs at the edge -- it's physically close to the passenger, not back at airline headquarters (the origin server). Decisions are made in milliseconds before the request ever reaches your application server.
+------------------------------------------------------------------------+
| THE AIRPORT SECURITY MODEL |
+------------------------------------------------------------------------+
| |
| Passenger Security Decision Destination |
| (Request) Checkpoint Point (Page) |
| (Middleware) |
| |
| GET /dashboard middleware.ts Has auth token? |
| ------> ------> / \ |
| YES NO |
| / \ |
| Continue Redirect |
| to /dashboard to /login |
| |
| GET /blog/hello middleware.ts Match /blog/*? |
| ------> ------> / \ |
| YES NO |
| / \ |
| Rewrite to Pass through |
| /en/blog/hello unchanged |
| |
+------------------------------------------------------------------------+
File Placement — The One Rule Everyone Forgets
Middleware in Next.js is defined in a single file. The placement is non-negotiable:
your-project/
middleware.ts <-- HERE (project root, same level as app/)
app/
page.tsx
dashboard/
page.tsx
api/
route.ts
next.config.js
package.json
If you use the src/ directory convention:
your-project/
src/
middleware.ts <-- HERE (inside src/, same level as app/)
app/
page.tsx
next.config.js
The rule: middleware.ts lives at the root of your project, or inside src/ if you use that convention. It sits next to the app/ directory, never inside it. There is exactly one middleware file per project. You cannot have multiple middleware files or nest them inside route folders.
This single file handles all routes. You use the matcher config or conditional logic inside the function to control which routes it applies to.
// middleware.ts — the basic skeleton
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
// This function runs before every matched request
export function middleware(request: NextRequest) {
// Inspect the request, make a decision, return a response
return NextResponse.next(); // Continue to the page as normal
}
// Optional: control which routes trigger this middleware
export const config = {
matcher: ['/dashboard/:path*', '/api/:path*'],
};
The Matcher Config — Controlling What Gets Intercepted
Without a matcher, middleware runs on every single request -- including static assets like images, fonts, and CSS files. That is almost never what you want. The matcher config tells Next.js which URL paths should trigger your middleware.
Basic Matcher Patterns
// Match a single path
export const config = {
matcher: '/dashboard',
};
// Match a path and all its children
export const config = {
matcher: '/dashboard/:path*',
};
// Match multiple paths
export const config = {
matcher: ['/dashboard/:path*', '/account/:path*', '/api/:path*'],
};
// Match everything except static files and images
// This is the most common production pattern
export const config = {
matcher: [
// Match all request paths except:
// - _next/static (static files)
// - _next/image (image optimization files)
// - favicon.ico (browser icon)
// - public folder assets
'/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
],
};
Conditional Logic Inside Middleware
Sometimes the matcher is not granular enough. You can combine the matcher with conditional logic inside the function:
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// Skip middleware for public routes
if (pathname.startsWith('/public') || pathname === '/') {
return NextResponse.next();
}
// Apply auth logic only to protected routes
if (pathname.startsWith('/dashboard') || pathname.startsWith('/account')) {
const token = request.cookies.get('session-token')?.value;
if (!token) {
return NextResponse.redirect(new URL('/login', request.url));
}
}
// Apply locale detection to all other routes
const locale = request.headers.get('accept-language')?.split(',')[0] || 'en';
const response = NextResponse.next();
response.headers.set('x-detected-locale', locale);
return response;
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
};
Redirects — Sending Users to a Different URL
A redirect changes the URL in the browser. The user sees the new URL in the address bar. Search engines follow redirects and update their index. Use redirects when the destination is genuinely a different page.
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// Permanent redirect (308) — old blog URLs to new structure
// 308 preserves the HTTP method (GET stays GET, POST stays POST)
if (pathname.startsWith('/blog/')) {
const slug = pathname.replace('/blog/', '');
return NextResponse.redirect(
new URL(`/articles/${slug}`, request.url),
308
);
}
// Temporary redirect (307) — maintenance page
if (pathname.startsWith('/shop') && process.env.MAINTENANCE_MODE === 'true') {
return NextResponse.redirect(new URL('/maintenance', request.url));
}
// Redirect unauthenticated users to login
if (pathname.startsWith('/dashboard')) {
const token = request.cookies.get('auth-token');
if (!token) {
// Preserve the original URL so we can redirect back after login
const loginUrl = new URL('/login', request.url);
loginUrl.searchParams.set('callbackUrl', pathname);
return NextResponse.redirect(loginUrl);
}
}
return NextResponse.next();
}
Status codes matter in interviews:
307-- Temporary redirect. "This page is temporarily somewhere else." Browser does NOT cache it.308-- Permanent redirect. "This page has moved forever." Browser caches it. Search engines transfer SEO ranking.301/302-- Older equivalents, but can change the HTTP method. Prefer 307/308 in modern apps.
Rewrites — Changing the Content Without Changing the URL
A rewrite serves content from a different route without changing the browser URL. The user still sees the original URL in their address bar, but the page content comes from somewhere else. This is powerful for A/B testing, multi-tenant apps, and internationalization.
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
const hostname = request.headers.get('host') || '';
// A/B testing: 50% of users see version B of the landing page
// The URL stays "/" but content comes from "/landing-b"
if (pathname === '/') {
const bucket = request.cookies.get('ab-bucket')?.value;
if (bucket === 'b') {
return NextResponse.rewrite(new URL('/landing-b', request.url));
}
}
// Multi-tenant: subdomain-based routing
// acme.example.com/pricing -> /tenants/acme/pricing
const subdomain = hostname.split('.')[0];
if (subdomain !== 'www' && subdomain !== 'example') {
return NextResponse.rewrite(
new URL(`/tenants/${subdomain}${pathname}`, request.url)
);
}
// Internationalization: detect locale and rewrite to localized route
// /about -> /en/about (content from localized folder, URL stays /about)
const locale = request.cookies.get('locale')?.value || 'en';
if (!pathname.startsWith(`/${locale}`)) {
return NextResponse.rewrite(
new URL(`/${locale}${pathname}`, request.url)
);
}
return NextResponse.next();
}
Redirect vs Rewrite -- the interview distinction:
+------------------------------------------------------------------+
| REDIRECT vs REWRITE |
+------------------------------------------------------------------+
| |
| REDIRECT: |
| User visits /old-page |
| Browser URL changes to /new-page |
| User sees /new-page in address bar |
| Two HTTP round trips (302/307/308 + new request) |
| Search engines update their index |
| |
| REWRITE: |
| User visits /page |
| Browser URL stays /page |
| Content is served from /different-page |
| One HTTP round trip (invisible to user) |
| Search engines index the original URL |
| |
+------------------------------------------------------------------+
Authentication Check — The Most Common Middleware Pattern
The number one use case for middleware in production is protecting routes behind authentication. Instead of adding auth checks inside every page component, middleware handles it in one centralized location.
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { jwtVerify } from 'jose'; // Edge-compatible JWT library
// Routes that require authentication
const protectedRoutes = ['/dashboard', '/account', '/settings', '/admin'];
// Routes that should redirect TO dashboard if user IS authenticated
const authRoutes = ['/login', '/register', '/forgot-password'];
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
const token = request.cookies.get('auth-token')?.value;
// Check if the token is valid (lightweight verification only)
let isAuthenticated = false;
if (token) {
try {
// Verify JWT signature without hitting a database
// jose works on edge runtime (no Node.js crypto dependency)
await jwtVerify(
token,
new TextEncoder().encode(process.env.JWT_SECRET)
);
isAuthenticated = true;
} catch {
// Token is invalid or expired — treat as unauthenticated
isAuthenticated = false;
}
}
// Protect dashboard and account routes
const isProtectedRoute = protectedRoutes.some(route =>
pathname.startsWith(route)
);
if (isProtectedRoute && !isAuthenticated) {
const loginUrl = new URL('/login', request.url);
loginUrl.searchParams.set('callbackUrl', pathname);
return NextResponse.redirect(loginUrl);
}
// Redirect logged-in users away from login/register pages
const isAuthRoute = authRoutes.some(route => pathname.startsWith(route));
if (isAuthRoute && isAuthenticated) {
return NextResponse.redirect(new URL('/dashboard', request.url));
}
// Role-based access: check for admin routes
if (pathname.startsWith('/admin') && isAuthenticated && token) {
const { payload } = await jwtVerify(
token,
new TextEncoder().encode(process.env.JWT_SECRET)
);
if (payload.role !== 'admin') {
return NextResponse.redirect(new URL('/unauthorized', request.url));
}
}
return NextResponse.next();
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)'],
};
Why middleware for auth instead of checking in each page?
- Centralized -- one file protects all routes. No chance of forgetting to add an auth check to a new page.
- Runs before rendering -- the protected page never even starts to render. No flash of authenticated content.
- Edge-fast -- runs at the CDN edge, adding less than 1ms of latency in most cases.
- Works with static pages -- even SSG pages can be protected because middleware runs before the cached page is served.
Geolocation-Based Routing
Next.js middleware has access to geolocation data through the request.geo object (available on Vercel and some other edge platforms). This enables country-specific routing without client-side detection.
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
const country = request.geo?.country || 'US'; // Default to US
const city = request.geo?.city || 'Unknown';
const region = request.geo?.region || 'Unknown';
// Country-based content localization
// Rewrite to country-specific version without changing URL
const countryLocaleMap: Record<string, string> = {
DE: 'de',
FR: 'fr',
JP: 'ja',
BR: 'pt-br',
ES: 'es',
};
const locale = countryLocaleMap[country] || 'en';
const { pathname } = request.nextUrl;
// Do not rewrite if already on a locale path or an API route
if (pathname.startsWith('/api') || pathname.match(/^\/(en|de|fr|ja|pt-br|es)\//)) {
return NextResponse.next();
}
// Rewrite to the localized version: /about -> /de/about for German users
return NextResponse.rewrite(new URL(`/${locale}${pathname}`, request.url));
}
// Another pattern: block or redirect traffic from specific countries
export function middlewareWithGeoBlocking(request: NextRequest) {
const country = request.geo?.country;
// Regulatory compliance: redirect EU users to EU-specific pages
const euCountries = ['DE', 'FR', 'IT', 'ES', 'NL', 'BE', 'AT', 'PL'];
if (country && euCountries.includes(country)) {
const { pathname } = request.nextUrl;
if (!pathname.startsWith('/eu/')) {
return NextResponse.rewrite(new URL(`/eu${pathname}`, request.url));
}
}
return NextResponse.next();
}
Interview note: request.geo is platform-dependent. On Vercel, it is populated automatically from the CDN edge. On self-hosted Node.js, you would need to parse geolocation from a service like MaxMind or use a reverse proxy (nginx, Cloudflare) that injects geo headers. This is a common follow-up question.
Rate Limiting Concept
Middleware is the natural place for rate limiting because it intercepts requests before they consume server resources. True rate limiting requires a shared state store (like Redis), but you can implement a basic version using headers and cookies.
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
// NOTE: This is a simplified concept for interviews.
// Production rate limiting requires an external store (Redis, Upstash).
// In-memory stores do not work because edge functions are stateless.
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// Only rate-limit API routes
if (!pathname.startsWith('/api')) {
return NextResponse.next();
}
// Identify the client (IP address or API key)
const ip = request.headers.get('x-forwarded-for') || 'anonymous';
const apiKey = request.headers.get('x-api-key');
// In production, you would check a Redis counter here:
// const count = await redis.incr(`rate-limit:${ip}`);
// await redis.expire(`rate-limit:${ip}`, 60); // 60 second window
// if (count > 100) { return rate limited response }
// Example using Upstash Redis (edge-compatible):
// import { Ratelimit } from '@upstash/ratelimit';
// import { Redis } from '@upstash/redis';
//
// const ratelimit = new Ratelimit({
// redis: Redis.fromEnv(),
// limiter: Ratelimit.slidingWindow(10, '10 s'), // 10 requests per 10s
// });
//
// const { success, limit, reset, remaining } = await ratelimit.limit(ip);
//
// if (!success) {
// return NextResponse.json(
// { error: 'Too many requests' },
// {
// status: 429,
// headers: {
// 'X-RateLimit-Limit': limit.toString(),
// 'X-RateLimit-Remaining': remaining.toString(),
// 'X-RateLimit-Reset': reset.toString(),
// },
// }
// );
// }
// Add rate limit headers to successful responses
const response = NextResponse.next();
response.headers.set('X-RateLimit-Limit', '100');
response.headers.set('X-RateLimit-Remaining', '99');
return response;
}
The interview talking point: Middleware runs on the edge runtime, which is stateless. You cannot store a counter in a local variable because each invocation may run on a different edge node. This is why production rate limiting requires an external distributed store like Upstash Redis, which is designed for edge access with sub-millisecond latency.
Edge Runtime — What It Means and What You Cannot Do
This is the part that trips up the most interview candidates. Middleware does not run on the standard Node.js runtime. It runs on the Edge Runtime, a lightweight JavaScript environment designed for speed and global distribution.
+------------------------------------------------------------------------+
| EDGE RUNTIME vs NODE.js RUNTIME |
+------------------------------------------------------------------------+
| |
| EDGE RUNTIME (middleware) NODE.js RUNTIME (API routes, pages) |
| --------------------------- ---------------------------------- |
| Runs at CDN edge locations Runs on origin server |
| Cold start: ~0ms Cold start: 50-250ms |
| Max execution: 25ms (Vercel) Max execution: 60s+ (configurable) |
| Max size: ~1-4MB No practical size limit |
| No Node.js APIs Full Node.js APIs |
| No fs, path, child_process fs, path, crypto, etc. |
| No native npm packages Any npm package |
| Web APIs only (fetch, crypto, All Web APIs + Node.js APIs |
| TextEncoder, URL, Headers) |
| Cannot connect to databases Can connect to databases |
| directly (no TCP sockets) directly (TCP/TLS) |
| |
+------------------------------------------------------------------------+
What You CAN Do in Middleware
// These all work in edge runtime:
// 1. Read and set cookies
const token = request.cookies.get('session')?.value;
const response = NextResponse.next();
response.cookies.set('visited', 'true', { maxAge: 86400 });
// 2. Read and set headers
const userAgent = request.headers.get('user-agent');
response.headers.set('x-custom-header', 'my-value');
// 3. Use the fetch API (call external services)
const res = await fetch('https://api.example.com/verify-token', {
method: 'POST',
body: JSON.stringify({ token }),
});
// 4. Use URL and URLSearchParams
const url = new URL(request.url);
url.searchParams.set('ref', 'middleware');
// 5. Use crypto (Web Crypto API, not Node.js crypto)
const encoder = new TextEncoder();
const data = encoder.encode('message');
// 6. Use edge-compatible libraries (jose for JWT, @upstash/redis, etc.)
What You CANNOT Do in Middleware
// These will FAIL in edge runtime:
// 1. File system access
import fs from 'fs'; // ERROR: fs is not available
fs.readFileSync('./config.json');
// 2. Node.js native modules
import path from 'path'; // ERROR: path is not available
import { execSync } from 'child_process'; // ERROR
// 3. Database drivers that use TCP sockets
import { Client } from 'pg'; // ERROR: no TCP socket support
import mongoose from 'mongoose'; // ERROR
// 4. Heavy npm packages with native bindings
import sharp from 'sharp'; // ERROR: native C++ bindings
import bcrypt from 'bcrypt'; // ERROR: use bcryptjs instead
// 5. Node.js crypto (use Web Crypto API instead)
import crypto from 'crypto'; // ERROR in edge runtime
// Use: crypto.subtle.digest() (Web Crypto API) instead
The mental model: Edge runtime has the same APIs as a Service Worker or Cloudflare Worker. If it works in a browser's Service Worker, it works in middleware. If it requires Node.js, it does not belong in middleware.
Napkin AI Visual Prompt: "Dark gradient (#0a0e1a -> #111827). Split diagram: left side labeled 'Edge Runtime' in emerald green (#10b981) with icons for cookies, headers, fetch, URL, Web Crypto (checkmarks). Right side labeled 'Node.js Runtime' in purple (#8b5cf6) with icons for fs, database, TCP, native modules (checkmarks). Center dividing line with a middleware.ts label at the top. Title: 'Edge vs Node.js Runtime'. White monospace text."
The Complete Request Lifecycle with Middleware
Understanding where middleware sits in the full request lifecycle is a high-value interview answer:
+------------------------------------------------------------------------+
| NEXT.js REQUEST LIFECYCLE |
+------------------------------------------------------------------------+
| |
| 1. Browser sends request |
| GET /dashboard |
| | |
| v |
| 2. CDN Edge receives request |
| | |
| v |
| 3. MIDDLEWARE runs (edge runtime) <--- You are here |
| - Check auth token |
| - Redirect / rewrite / modify headers |
| - Decide: continue, redirect, or block |
| | |
| v |
| 4. Route matching (file-based routing) |
| - Find the matching page.tsx / route.ts |
| | |
| v |
| 5. Static check: is this page cached? |
| - If SSG/ISR and cached: serve from cache |
| - If SSR or cache miss: continue to server |
| | |
| v |
| 6. Server rendering (if needed) |
| - Execute server components |
| - Run data fetching |
| - Generate HTML |
| | |
| v |
| 7. Response sent to browser |
| |
+------------------------------------------------------------------------+
Middleware is step 3 -- it runs after the CDN edge receives the request but before any routing, caching, or rendering happens. This is why it can protect even statically generated pages.
Common Mistakes
1. Putting middleware.ts inside the app/ directory.
Middleware must be at the project root (next to app/) or inside src/ (next to src/app/). Placing it inside app/ or a subfolder silently does nothing -- no error, no warning, just a middleware that never runs. This is the number one debugging headache.
2. Not using a matcher, then wondering why middleware is slow.
Without a matcher, middleware runs on every request -- including every image, font, CSS file, and JavaScript chunk. This can add measurable latency. Always add a matcher that excludes _next/static, _next/image, and static assets.
3. Trying to use Node.js APIs in middleware.
Importing fs, path, database drivers, or packages with native bindings crashes middleware. The edge runtime only supports Web APIs. Use edge-compatible alternatives: jose instead of jsonwebtoken, @upstash/redis instead of ioredis, bcryptjs instead of bcrypt.
4. Doing heavy computation or database queries in middleware. Middleware should be fast -- ideally under 1-5ms. It runs on every request. Expensive operations like database lookups, complex JWT decoding with multiple round-trips, or calling slow external APIs defeat the purpose. Keep middleware lightweight: verify a token signature, check a cookie, read a header. Move heavy logic to server components or API routes.
5. Forgetting that middleware cannot directly return page content.
Middleware can redirect, rewrite, set headers, and set cookies. It cannot render HTML or return a React component. If you need to show a "you are blocked" page, redirect to a /blocked route that renders the page -- don't try to return HTML from middleware.
6. Using in-memory variables for rate limiting or state. Edge functions are stateless. A variable declared outside the middleware function is not shared across requests or edge nodes. Each invocation starts fresh. For any shared state (counters, session stores), use an external service.
Interview Questions
1. "Where does middleware.ts go in a Next.js project, and what happens if you put it in the wrong place?"
middleware.ts must be placed at the root of the project, at the same level as the app/ directory. If you use the src/ convention, it goes inside src/, next to src/app/. There is exactly one middleware file per project. If you put it inside app/, inside a route folder, or anywhere else, Next.js silently ignores it -- there is no build error or runtime warning, the middleware simply never executes. This is a common debugging trap because the failure mode is silent.
2. "Explain the difference between a redirect and a rewrite in middleware. When would you use each?"
A redirect (using NextResponse.redirect()) sends a 3xx HTTP response that tells the browser to navigate to a different URL. The address bar changes, and two round trips happen. Use redirects when the user should see a different URL -- for example, redirecting /old-blog/post to /articles/post permanently (308), or sending unauthenticated users to /login. A rewrite (using NextResponse.rewrite()) serves content from a different route without changing the browser URL. The user still sees the original URL but gets different content. Use rewrites for A/B testing (same URL, different landing pages), multi-tenant apps (subdomain routing), and internationalization (locale-prefixed content without locale in the URL).
3. "Why does Next.js middleware run on the edge runtime instead of the Node.js runtime? What are the trade-offs?"
The edge runtime enables middleware to run at CDN edge locations geographically close to the user, reducing latency to near zero. Since middleware runs on every matched request, even a few milliseconds of added latency would compound across all users. The trade-off is a restricted API surface: no file system access, no native Node.js modules, no TCP-based database connections, and a limited execution time (often 25ms on Vercel). You can only use Web APIs (fetch, crypto.subtle, TextEncoder, URL, Headers) and edge-compatible npm packages. This forces middleware to stay lightweight, which is by design -- it should make fast decisions (redirect, rewrite, modify headers), not do heavy computation.
4. "How would you implement authentication middleware that protects some routes but not others?"
I would define an array of protected route prefixes and check the request pathname against them. For the auth check itself, I would read a JWT from the cookies using request.cookies.get(), then verify the signature using an edge-compatible library like jose (not jsonwebtoken, which requires Node.js crypto). If verification fails or the cookie is missing, I would redirect to /login with a callbackUrl query parameter preserving the original destination. Public routes like /, /login, and /about would be excluded from the check. I would also handle the reverse case -- redirect already-authenticated users away from /login back to /dashboard. The matcher config would exclude static assets (_next/static, _next/image) to avoid running auth logic on non-page requests.
5. "Can middleware replace server-side auth checks in page components? Why or why not?"
Middleware is a first line of defense, not a complete replacement. It is excellent for route-level access control -- preventing unauthenticated users from reaching protected pages at all. However, middleware should only do lightweight verification (check that a token exists and has a valid signature). It should not do authorization checks that require database queries ("does this user have permission to edit this specific resource?") because edge runtime cannot connect directly to databases and the execution time is limited. Fine-grained permission checks should still happen in server components or server actions where you have full Node.js access. Think of middleware as the bouncer at the door (checks your ID) and server components as the concierge inside (checks your reservation for a specific room).
Quick Reference — Middleware Cheat Sheet
| Concept | Details |
|---|---|
| File location | Project root middleware.ts or src/middleware.ts |
| Number of files | Exactly one per project |
| Runtime | Edge Runtime (not Node.js) |
| Runs when | Before every matched request (before routing and rendering) |
| Matcher syntax | '/path/:param*' for dynamic segments, regex in array |
| Redirect | NextResponse.redirect(new URL('/target', request.url)) |
| Rewrite | NextResponse.rewrite(new URL('/target', request.url)) |
| Continue | NextResponse.next() |
| Read cookies | request.cookies.get('name')?.value |
| Set cookies | response.cookies.set('name', 'value', options) |
| Read headers | request.headers.get('header-name') |
| Set headers | response.headers.set('header-name', 'value') |
| Geolocation | request.geo?.country, request.geo?.city (platform-dependent) |
| Auth library | Use jose for JWT (edge-compatible), not jsonwebtoken |
| Rate limiting | Use @upstash/ratelimit + @upstash/redis for edge stores |
| Cannot do | File system, native modules, TCP databases, heavy computation |
| Execution limit | ~25ms on Vercel, varies by platform |
| Status codes | 307 (temp redirect), 308 (permanent redirect), 429 (rate limit) |
+----------------------------------------------------------+
| MIDDLEWARE DECISION FLOW |
+----------------------------------------------------------+
| |
| Request arrives |
| | |
| v |
| Does it match the matcher config? |
| / \ |
| YES NO --> Skip middleware, serve directly |
| | |
| v |
| Is the user authenticated? |
| / \ |
| YES NO --> Redirect to /login |
| | |
| v |
| Does the URL need rewriting? |
| (A/B test, locale, multi-tenant) |
| / \ |
| YES NO |
| | | |
| v v |
| NextResponse NextResponse |
| .rewrite() .next() |
| |
+----------------------------------------------------------+
Prev: Lesson 5.3 -- Parallel Routes & Intercepting Routes Next: Lesson 6.1 -- Route Handlers (App Router) ->
This is Lesson 5.4 of the Next.js Interview Prep Course -- 8 chapters, 33 lessons.