Aug 2026 · Guide · ~10 min read
Next.js Middleware — What It Is and When to Use It
By Safdar Ali — frontend engineer, Bengaluru
I'm Safdar Ali. Next.js middleware runs on the Edge before your page renders — auth gates, geo redirects, A/B flags. It is also the layer I see add 150–300ms to every request when teams fetch databases or call slow APIs inside middleware.ts. This nextjs middleware guide covers what it is, when it helps, and when to move logic to layouts or route handlers instead.
Rewrites are underrated. Instead of redirecting /old-blog to /blog, middleware can rewrite internally so the URL stays stable for SEO while serving new routes. Redirects cost a round trip; rewrites do not. I use NextResponse.rewrite for legacy WordPress paths on client migrations and for serving maintenance pages without changing the public URL during deploys.
What Next.js middleware is — and what it is not
A single middleware.ts at the project root (or under src) exports a middleware function that runs on matching routes. It can rewrite, redirect, or set headers. It is not a replacement for API routes, Server Actions, or React Server Components — it is a thin gate at the edge.
// middleware.ts — runs before matched routes
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export function middleware(request: NextRequest) {
console.log("path:", request.nextUrl.pathname);
return NextResponse.next();
}
export const config = {
matcher: ["/dashboard/:path*", "/admin/:path*"],
};The matcher is critical. Run middleware on everything and you tax static assets and public blog pages. I scope matchers to authenticated zones only.
Middleware executes in a constrained runtime — no Node.js APIs like fs, limited npm packages, and strict CPU time limits on Vercel Edge. That is by design. The edge is for fast decisions with minimal dependencies. When you need bcrypt password verification or a Prisma query, you are in Node territory — Route Handlers, Server Actions, or layout Server Components. Confusing the two leads to build errors or runtime failures that only show up in production deploys, not on localhost where middleware sometimes runs in a more permissive dev mode.
Think of middleware as a reverse proxy hook that runs before your Next.js route handler or static file response. It sees the raw request — cookies, headers, geo, URL — and returns a response or forwards with modifications. It does not render React. It does not access your component tree. Teams that treat middleware as a mini backend often duplicate logic that already belongs in lib/auth.ts and wonder why changes require editing two files.
NextResponse.rewrite versus redirect confuses teams new to nextjs middleware. Redirect changes the URL in the browser — users see /login. Rewrite keeps the URL but serves different content — useful for geo pricing at /pricing. Choose intentionally; SEO and analytics depend on it. I document which routes use rewrite in README so the next developer does not add a redirect that breaks canonical URLs marketing already submitted to Search Console.
Auth redirect — protect dashboard routes
The classic pattern: read a session cookie, redirect unauthenticated users to login. Keep verification cheap — JWT decode or signed cookie check, not a database round-trip per navigation.
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
const SESSION_COOKIE = "session_token";
export function middleware(request: NextRequest) {
const token = request.cookies.get(SESSION_COOKIE)?.value;
if (!token) {
const login = new URL("/login", request.url);
login.searchParams.set("from", request.nextUrl.pathname);
return NextResponse.redirect(login);
}
// Optional: verify JWT signature with jose (sync, no DB)
return NextResponse.next();
}
export const config = {
matcher: ["/dashboard/:path*"],
};// BEFORE — database lookup in middleware (slow on every click)
const user = await prisma.user.findUnique({ where: { id } });
// AFTER — verify signed cookie in middleware; load user in Server Component
import { jwtVerify } from "jose";
const { payload } = await jwtVerify(token, secret);
// pass payload.sub to layout via header if neededSession refresh is a related pattern. Middleware can detect an expired JWT and redirect to /login, but refreshing tokens belongs in a Route Handler or Server Action where you can set httpOnly cookies safely. I expose a lightweight session version in the cookie and compare it to a header set by middleware after verification — enough to block stale sessions without loading the full user row on every navigation.
Role-based access at the edge works for coarse gates — is this user an admin? — when the claim lives in the JWT payload. Fine-grained permissions — can this user edit invoice 4821? — belong in Server Components where you can query once and cache with React cache(). Putting permission matrices in middleware guarantees latency and stale rules when roles change mid-session.
OAuth callbacks are a common middleware footgun — if your matcher protects /dashboard but not /api/auth/callback, sessions never establish. Exclude NextAuth and Clerk callback paths explicitly. I keep a comment list of public routes at the top of middleware.ts: /login, /signup, /api/auth/*, /_next/*, /favicon.ico. Review the list every time you add a marketing landing page that must stay public.
Geo routing — India vs global pricing page
Vercel and other edge hosts expose country on the request. Middleware can rewrite to locale-specific paths without client JavaScript.
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export function middleware(request: NextRequest) {
const country = request.geo?.country ?? "US";
const pathname = request.nextUrl.pathname;
if (pathname === "/pricing") {
if (country === "IN") {
return NextResponse.rewrite(new URL("/pricing/in", request.url));
}
return NextResponse.rewrite(new URL("/pricing/global", request.url));
}
return NextResponse.next();
}
export const config = {
matcher: ["/pricing"],
};For Bengaluru users on Jio/Airtel, avoiding a client-side redirect saves a round trip and improves LCP on pricing pages.
Geo routing also helps compliance and content localization without building separate deployments. Rewrite to /pricing/in serves INR pricing, UPI copy, and India-specific testimonials from the same codebase. The URL bar can stay /pricing while the rendered page differs — good for SEO when you want one canonical URL with localized body content. Alternatively, redirect to /in/pricing if you need distinct hreflang entries; middleware supports both patterns in under twenty lines.
Test geo locally with Vercel CLI environment overrides or by mocking request.geo in unit tests. I once shipped a rewrite that sent all unknown countries to global pricing but forgot to handle VPN users — support tickets about wrong currency taught me to log country codes in staging and verify with colleagues on different networks across Bengaluru before launch.
Bot blocking on /admin is middleware-friendly — check User-Agent or known scanner IPs, return 403 before admin HTML renders. Keep the list short and maintained; aggressive blocking hurts legitimate uptime monitors. Pair with rate limiting at the host or upstream CDN when attacks scale beyond a few lines in middleware.
A/B tests and security headers — lightweight middleware wins
export function middleware(request: NextRequest) {
const response = NextResponse.next();
// Stable bucket from cookie or random assignment
const bucket = request.cookies.get("ab_home")?.value ?? (Math.random() < 0.5 ? "a" : "b");
response.cookies.set("ab_home", bucket, { path: "/", maxAge: 60 * 60 * 24 * 30 });
response.headers.set("x-ab-bucket", bucket);
response.headers.set("X-Frame-Options", "DENY");
response.headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
return response;
}A/B bucket assignment in middleware keeps experiments consistent across page loads — the cookie persists for thirty days in the example above. Your Server Components read x-ab-bucket from headers or the cookie to render variant A or B HTML. That beats client-side split tests that flash control content before JavaScript hydrates, which both skews results and hurts Core Web Vitals on mobile networks common in India.
Security headers belong here too because they apply uniformly without touching every route. Content-Security-Policy is longer and site-specific — I often set CSP in next.config headers for static policies and use middleware only for dynamic nonce injection on HTML responses. Start with X-Frame-Options, Referrer-Policy, and Permissions-Policy; add strict CSP when you have bandwidth to test every third-party script embed.
Performance pitfalls — the 200ms tax nobody budgets for
Middleware runs on every matched request including client navigations in some setups. Heavy work here blocks TTFB for the whole subtree. Symptoms: dashboard feels sluggish, Lighthouse TTFB spikes, edge function duration warnings in Vercel.
// Pitfalls — do NOT do these in middleware
// 1. await fetch("https://api.example.com/permissions") on every request
// 2. Prisma / Drizzle queries
// 3. Large JSON parsing of entire session blobs
// 4. matcher: "/((?!_next/static).*)" // too broad
// Move to:
// - Server Component: await getSession() once per page
// - Route Handler: POST /api/auth/refresh
// - Layout: shared auth for /dashboard segmentWhen I moved permission checks from middleware into a dashboard layout Server Component, perceived navigation improved because middleware stopped awaiting HTTP. Same auth outcome, better latency — aligns with patterns in my RSC guide.
Measure middleware impact in Vercel Analytics or your host's edge function dashboard. If p95 edge duration exceeds 50ms, audit every await in middleware.ts — there should be none or nearly none. Compare TTFB on a route with middleware disabled versus enabled using WebPageTest from Mumbai if available. The delta is your middleware tax; justify it against the security or routing benefit. Sometimes the answer is narrowing the matcher, not optimizing the function body.
Edge middleware also runs on prefetch requests when users hover Next.js Link components. That surprises teams who thought middleware only ran on full page loads. A heavy middleware on /blog/* slows perceived navigation across the entire site when users skim link previews. Scope matchers tightly so prefetch traffic on public routes never touches auth logic.
middleware.ts at src/ versus project root must match your folder layout — duplicate files cause silent non-execution. If middleware never logs in development, verify the file path Next.js documents for your version and that config.matcher paths include the route you are testing. Monorepos with multiple packages need middleware in the app package root, not the repo root unless configured otherwise.
Middleware vs layout auth — decision flow
Use middleware when you must redirect before any HTML ships — unauthenticated users should never see dashboard shell flash. Use layout-level auth when you need full user object from DB and can tolerate running on the server after the edge gate passed a lightweight cookie check.
Internationalization middleware can set locale cookies and rewrite to /en or /hi paths before render — same mechanism as geo routing. Keep locale detection cheap: Accept-Language header first, optional geo hint second. Do not call translation APIs in middleware; load messages in Server Components or next-intl config. Combining i18n rewrite with auth middleware means ordering matters — auth first, locale second, or you redirect Hindi users to English login by mistake.
// app/dashboard/layout.tsx — after middleware cookie gate
import { redirect } from "next/navigation";
import { getUserFromSession } from "@/lib/auth";
export default async function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
const user = await getUserFromSession();
if (!user) redirect("/login");
return <DashboardShell user={user}>{children}</DashboardShell>;
}The two-layer pattern — middleware checks cookie exists, layout loads user — is what I ship on most SaaS dashboards from Bengaluru. Middleware stops anonymous HTML from leaking dashboard chrome. Layout fetches name, avatar, and plan tier once per navigation tree. Route handlers stay free for mutations. Document this split in onboarding docs so new engineers do not add getUser() to middleware because it feels convenient in the moment.
Local development and debugging middleware
// Test geo locally — .env.local
// VERCEL_IP_COUNTRY=IN (when using Vercel CLI)
// Log only in development
export function middleware(request: NextRequest) {
if (process.env.NODE_ENV === "development") {
console.log("[mw]", request.nextUrl.pathname, request.geo?.country);
}
return NextResponse.next();
}If middleware "does nothing," check matcher paths and that the file lives at root or src root. If redirects loop, check that /login is excluded from the auth matcher.
Redirect loops happen when your matcher includes /login and your middleware redirects unauthenticated users to /login — infinite loop. Exclude auth routes explicitly: matcher: ["/dashboard/:path*", "/settings/:path*"]. Another gotcha is trailing slashes — /dashboard and /dashboard/ may match differently depending on next.config trailingSlash setting. Test both paths after deploy .
Bot blocking on /admin and rate limiting by IP prefix are edge-appropriate — cheap checks that prevent brute force before your origin sees traffic. Pair with CAPTCHA on the login form itself; middleware alone cannot solve credential stuffing. Log blocked requests in development; in production send counts to your APM so spikes show up before the bill does.
When I use middleware in production
Yes: auth cookie presence, geo rewrite, security headers, bot blocking on admin paths. No: business logic, ORM calls, permission matrices, heavy external APIs. Nextjs middleware is a bouncer, not the kitchen.
Before adding middleware to a new project, write down what problem it solves that layouts or headers cannot. If the answer is "nothing, but the tutorial did it," skip the file. If the answer is "stop unauthenticated HTML on /admin," implement a twenty-line cookie check and move on. Nextjs middleware is powerful precisely because it runs early — that same property makes it expensive when misused. Ship the thinnest gate that meets your requirement, measure TTFB, and expand only with evidence.
Next.js 15 continues to refine middleware runtime APIs — check release notes when upgrading. matcher arrays support negative patterns in newer versions; read current docs rather than copying a 2024 Stack Overflow answer. If middleware is not running at all after upgrade, verify export name is middleware not middlewares and file location matches src directory layout. These sound trivial; they burn hours when a production deploy gate is down before a demo in Bengaluru.
Related: Next.js vs React, deploy Next.js free, contact.
If this helped you
I publish free tutorials and write-ups like this in my spare time — no paywall on the guides. If it saved you an afternoon of trial and error, you can support the work:
- Buy me a coffee at buymeacoffee.com/safdarali
- Subscribe to my YouTube channel — it's free; 70+ React & Next.js tutorials
Related reading
More guides on safdarali.in — same author, production-focused.
- Guide
How to Build a Frontend Developer Portfolio That Stands Out
Frontend developer portfolio guide for India — sections, React/Next.js examples, SEO, performance, personal branding, FAQ, and checklist to build and rank.
May 2026Read article →
- Guide
React Server Components vs Client Components — When to Use Which
Practical RSC vs client guide for Next.js App Router — when to use each, real code, bundle before/after, and performance impact.
May 2026Read article →