Middleware in Next.js runs before every matched request. It's the right place for authentication checks, redirects, and route protection — logic that needs to run consistently regardless of which page hits first.
Here's the production-ready pattern for middleware auth in App Router, including the edge cases that catch most implementations off-guard. The same approach runs in production at pixova.io/blog/free-ai-background-generator.
What Middleware Can and Can't Do
Middleware runs at the edge — fast, close to users, before server or database hits. That speed comes with constraints.
Can do: Read/write cookies, read request headers, redirect requests, rewrite URLs, return early responses.
Can't do: Hit databases directly, use most Node.js APIs, call server-side libraries requiring Node runtime.
This means authentication in middleware needs to verify tokens without a database call. JWT-based auth is the standard solution.
Basic Structure
// middleware.ts — at project root, NOT inside /app
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('auth-token')?.value;
const isProtected = pathname.startsWith('/dashboard') ||
pathname.startsWith('/account');
if (isProtected && !token) {
const loginUrl = new URL('/login', request.url);
loginUrl.searchParams.set('redirect', pathname);
return NextResponse.redirect(loginUrl);
}
return NextResponse.next();
}
export const config = {
matcher: ['/dashboard/:path*', '/account/:path*'],
};
The matcher config is critical — without it, middleware runs on every request including static assets and Next.js internals, which creates unnecessary overhead.
JWT Verification at the Edge
Standard jsonwebtoken doesn't run at the edge. Use jose instead:
npm install jose
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { jwtVerify } from 'jose';
const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET!);
async function verifyToken(token: string) {
try {
const { payload } = await jwtVerify(token, JWT_SECRET);
return payload;
} catch {
return null;
}
}
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
const token = request.cookies.get('auth-token')?.value;
const isProtected = pathname.startsWith('/dashboard');
const isAuthRoute = pathname === '/login' || pathname === '/signup';
if (isProtected) {
if (!token) {
return redirectToLogin(request, pathname);
}
const payload = await verifyToken(token);
if (!payload) {
// Token invalid/expired — clean it up and redirect
const response = redirectToLogin(request, pathname);
response.cookies.delete('auth-token');
return response;
}
// Valid — pass user context to downstream routes via headers
const requestHeaders = new Headers(request.headers);
requestHeaders.set('x-user-id', payload.sub as string);
requestHeaders.set('x-user-role', payload.role as string);
return NextResponse.next({ request: { headers: requestHeaders } });
}
// Redirect already-authenticated users away from login/signup
if (isAuthRoute && token) {
const payload = await verifyToken(token);
if (payload) {
return NextResponse.redirect(new URL('/dashboard', request.url));
}
}
return NextResponse.next();
}
function redirectToLogin(request: NextRequest, from: string) {
const url = new URL('/login', request.url);
url.searchParams.set('redirect', from);
return NextResponse.redirect(url);
}
export const config = {
matcher: ['/dashboard/:path*', '/account/:path*', '/login', '/signup'],
};
Reading User Context in Server Components
The headers set by middleware flow through to Server Components:
// app/dashboard/page.tsx
import { headers } from 'next/headers';
export default async function DashboardPage() {
const headersList = headers();
const userId = headersList.get('x-user-id');
const role = headersList.get('x-user-role');
// Only fetch user data — auth already handled by middleware
const userData = await fetchUserData(userId!);
return <Dashboard user={userData} role={role} />;
}
This pattern means Server Components handle data fetching. Middleware handles auth. Clean separation.
Role-Based Access Control
type Role = 'user' | 'admin' | 'moderator';
const routeAccess: Record<string, Role[]> = {
'/admin': ['admin'],
'/moderation': ['admin', 'moderator'],
'/dashboard': ['user', 'admin', 'moderator'],
};
function canAccess(pathname: string, role: Role): boolean {
const matched = Object.keys(routeAccess).find(r => pathname.startsWith(r));
if (!matched) return true;
return routeAccess[matched].includes(role);
}
// Inside middleware, after verifying token:
const role = payload.role as Role;
if (!canAccess(pathname, role)) {
return NextResponse.redirect(new URL('/unauthorized', request.url));
}
Rate Limiting in Middleware
Middleware is the right place for basic rate limiting before API routes:
const requestCounts = new Map<string, { count: number; reset: number }>();
function rateLimit(ip: string, limit = 60, windowMs = 60_000): boolean {
const now = Date.now();
const record = requestCounts.get(ip);
if (!record || now > record.reset) {
requestCounts.set(ip, { count: 1, reset: now + windowMs });
return true;
}
if (record.count >= limit) return false;
record.count++;
return true;
}
// In middleware, before other checks:
if (request.nextUrl.pathname.startsWith('/api/')) {
const ip = request.ip ??
request.headers.get('x-forwarded-for')?.split(',')[0] ??
'unknown';
if (!rateLimit(ip)) {
return new NextResponse('Too Many Requests', {
status: 429,
headers: { 'Retry-After': '60' },
});
}
}
Note: in-memory rate limiting only works for single-instance deployments. For multi-instance, use Redis via an edge-compatible client.
Common Pitfalls
Infinite redirect loops. Middleware that protects /login will redirect to /login, which triggers middleware again. Fix: explicitly exclude auth routes from the protected route check, and use the matcher config carefully.
Missing matcher config. Without matcher, middleware runs on /_next/static, /_next/image, and favicon requests. Always set the matcher.
Wrong library for JWT. jsonwebtoken won't run at the edge. Use jose — it's designed for edge environments and has the same JWT capabilities.
Not cleaning up expired tokens. If you redirect on invalid token, also delete the cookie — otherwise the user arrives at the login page with an expired cookie that will fail again if they navigate back.
Testing with expired tokens. Your manual testing probably uses fresh tokens. Write tests specifically for the expired token flow — it's where the most user-visible bugs hide.
Testing
// Tests require NextRequest, not standard fetch mocks
import { NextRequest } from 'next/server';
import { middleware } from '../middleware';
test('unauthenticated → redirect to login', async () => {
const req = new NextRequest('https://example.com/dashboard');
const res = await middleware(req);
expect(res.status).toBe(307);
const location = res.headers.get('location')!;
expect(location).toContain('/login');
expect(location).toContain('redirect=%2Fdashboard');
});
test('authenticated → passes through with user headers', async () => {
const token = createTestJWT({ sub: 'u-123', role: 'user' });
const req = new NextRequest('https://example.com/dashboard');
req.cookies.set('auth-token', token);
const res = await middleware(req);
expect(res.status).toBe(200);
});
Summary
Middleware is the right layer for auth because it runs before any route handler, handles the logic once regardless of which protected page is hit, and runs fast at the edge without needing a database call.
The key design decision: middleware verifies whether the user is authenticated and passes context through headers. Server Components and API routes handle what the authenticated user can access and see.
One More Edge Case — Middleware and Static Exports
If you're using next export (static site generation), middleware doesn't run — there's no server. Static exports and middleware are incompatible.
This is usually obvious but catches people who start with dynamic middleware and later try to add static export for performance. If you need static export, move auth logic to the client side or use a CDN-level auth proxy instead of Next.js middleware.
For standard Vercel or server deployments, middleware runs at the edge without issues. The incompatibility only affects the specific case of next export.
Debugging Middleware
Middleware runs at the edge, which means console.log doesn't appear in your terminal the same way server-side logs do. To debug:
// Use edge-compatible logging
export async function middleware(request: NextRequest) {
// This appears in edge function logs, not terminal
console.log('Middleware hit:', request.nextUrl.pathname);
// In Vercel, edge logs appear in the Functions tab
// Locally, they appear in the terminal with [Middleware] prefix
// ...rest of middleware
}
Vercel's dashboard shows edge function logs separately from server logs — look in the Functions tab, not the standard log view. Locally, next dev prefixes middleware logs with [Middleware] to distinguish them.
Setting NEXT_PUBLIC_DEBUG=true in local .env and adding conditional verbose logging during development is a common pattern for middleware-heavy applications.
Top comments (0)