Skip to main content

NextJS Middleware: Protected Routes Step-by-Step

Next.js Middleware runs on the edge, before your route handlers or pages are executed, giving you a single place to enforce authentication and authorization for your entire app. Middleware intercepts every request, checks if the user's session is valid, and either allows the request to proceed or redirects to login. This pattern is more secure and maintainable than adding auth checks to every individual route because it centralizes logic and eliminates the risk of accidentally leaving a route unprotected.

How Middleware Validates Sessions and Redirects

Middleware receives a NextRequest object containing cookies, headers, and the requested URL. It returns a NextResponse that either allows the request to continue or redirects the user elsewhere. Session validation in middleware follows a simple flow: extract the session cookie, verify it against your store, and either attach user data to the request or send the user to login.

// middleware.ts
import { NextRequest, NextResponse } from 'next/server';
import { getSession } from '@/lib/sessions';

export async function middleware(request: NextRequest) {
const path = request.nextUrl.pathname;

// List of paths that require authentication
const protectedPaths = ['/dashboard', '/profile', '/settings', '/admin'];
const isProtected = protectedPaths.some(p => path.startsWith(p));

// Public paths that do not need authentication
const publicPaths = ['/login', '/signup', '/'];
const isPublic = publicPaths.includes(path);

if (isPublic) {
return NextResponse.next();
}

if (!isProtected) {
// Unspecified paths are allowed without auth
return NextResponse.next();
}

// For protected paths, validate session
const sessionId = request.cookies.get('next_session')?.value;
if (!sessionId) {
// No session; redirect to login
return NextResponse.redirect(new URL('/login', request.url));
}

const session = await getSession(sessionId);
if (!session) {
// Session is invalid or expired
return NextResponse.redirect(new URL('/login', request.url));
}

// Session is valid; pass user data to downstream handlers
const requestHeaders = new Headers(request.headers);
requestHeaders.set('x-user-id', session.userId);
requestHeaders.set('x-user-role', session.role);
return NextResponse.next({ request: { headers: requestHeaders } });
}

export const config = {
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
};

The config.matcher glob tells Next.js which requests should invoke this middleware. The pattern above excludes static files and API routes; you can adjust it to include or exclude specific paths. For example, matcher: ['/dashboard/:path*'] would only run middleware for the /dashboard section.

Creating a Protected Page Component

Once middleware validates the session and attaches user data to request headers, your Server Components can read it. Here is a protected page that displays user-specific content:

// app/dashboard/page.tsx
import { headers } from 'next/headers';
import { redirect } from 'next/navigation';

export default async function DashboardPage() {
const headersList = await headers();
const userId = headersList.get('x-user-id');
const userRole = headersList.get('x-user-role');

// If middleware did not set these headers, the user is not authenticated
if (!userId) {
redirect('/login');
}

return (
<div>
<h1>Welcome back, {userId}</h1>
<p>Your role: {userRole}</p>
<p>This page is only visible to authenticated users.</p>
</div>
);
}

Using server-side headers() and redirect() ensures authentication checks happen on the server and cannot be bypassed by client-side code. The redirect() function throws an error that Next.js catches, replacing the current page with the login page.

Implementing Role-Based Route Protection

Middleware can also enforce role-based access: a user might be authenticated, but only admins can access /admin. Here is how to check roles:

// middleware.ts (enhanced with role checks)
import { NextRequest, NextResponse } from 'next/server';
import { getSession } from '@/lib/sessions';

const roleBasedPaths: Record<string, string[]> = {
'/admin': ['admin'],
'/moderator': ['admin', 'moderator'],
'/dashboard': ['user', 'admin', 'moderator'],
};

export async function middleware(request: NextRequest) {
const path = request.nextUrl.pathname;

// Check if this path requires a specific role
const requiredRoles = Object.entries(roleBasedPaths).find(([pathPrefix]) =>
path.startsWith(pathPrefix)
)?.[1];

if (!requiredRoles) {
return NextResponse.next();
}

const sessionId = request.cookies.get('next_session')?.value;
if (!sessionId) {
return NextResponse.redirect(new URL('/login', request.url));
}

const session = await getSession(sessionId);
if (!session) {
return NextResponse.redirect(new URL('/login', request.url));
}

// Check if user's role is allowed
if (!requiredRoles.includes(session.role)) {
return NextResponse.redirect(new URL('/unauthorized', request.url));
}

const requestHeaders = new Headers(request.headers);
requestHeaders.set('x-user-id', session.userId);
requestHeaders.set('x-user-role', session.role);
return NextResponse.next({ request: { headers: requestHeaders } });
}

export const config = {
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
};

This approach scales: add new paths to the roleBasedPaths object, and middleware will enforce them automatically. If a user attempts to access /admin but their role is user, they are redirected to /unauthorized.

Handling Redirect Loops and Edge Cases

One common issue: if the login page itself requires authentication, you create an infinite redirect loop. Always ensure that login and signup routes are excluded from middleware checks:

// middleware.ts (safe login path)
export async function middleware(request: NextRequest) {
const path = request.nextUrl.pathname;

// Allow these paths without authentication
const publicPaths = ['/login', '/signup', '/forgot-password', '/'];
if (publicPaths.includes(path)) {
return NextResponse.next();
}

// ... rest of middleware
}

Similarly, if a user is already logged in and tries to access /login, you might want to redirect them to /dashboard:

// app/login/page.tsx
import { headers } from 'next/headers';
import { redirect } from 'next/navigation';

export default async function LoginPage() {
const headersList = await headers();
const userId = headersList.get('x-user-id');

// If already logged in, go to dashboard
if (userId) {
redirect('/dashboard');
}

return (
<form action="/api/login" method="POST">
<input type="email" name="email" required />
<input type="password" name="password" required />
<button type="submit">Log In</button>
</form>
);
}

Key Takeaways

  • Middleware runs on the edge before your routes, centralizing authentication logic.
  • Session validation in middleware prevents the need to check auth in every route.
  • Use request.cookies in middleware (not cookies()) to read cookies from the request.
  • Attach user data to request headers so downstream components can access it via headers().
  • Define a config.matcher glob to control which requests invoke middleware.
  • Always exclude public paths like /login from protected routes to avoid redirect loops.

Frequently Asked Questions

Can I use middleware for API route protection?

Yes, middleware applies to API routes too. However, you might prefer to validate sessions inside your route handler for more granular control. Use middleware for broad policies (all routes must have a session) and route handlers for specific logic (only admins can delete users).

How do I test middleware locally?

Run npm run dev and navigate to a protected route. Your browser should either show the page (if authenticated) or redirect to login. Use your browser's DevTools to inspect cookies and request headers to debug session issues.

Is middleware on the edge or the server?

In development, middleware runs in Node.js on your machine. On Vercel, middleware runs on the edge (fast, globally distributed). In self-hosted environments, middleware runs on your server. The behavior is the same across all three.

Can middleware access the database?

Yes, middleware can query your database via the same libraries you use in route handlers. In-memory stores (caches) are faster, but edge middleware has latency, so database queries might be slow. For production, consider caching session data in Redis or a distributed cache.

Further Reading