# Middleware Route Protection in Next.js: Stop Copy-Pasting Auth Checks
By Govind Bajaj · 2026

Tags: next.js, middleware, auth, security, app-router
I found the same auth check in 14 different page files. It was slightly different in 6 of them. Two had security bugs. Middleware fixed all of it in 12 lines.
I reviewed a Next.js codebase last month and found the same authentication check in fourteen different page files.

It was slightly different in six of them. Two had security bugs — one checked the wrong property, another did not redirect unauthenticated users. Every page that needed protection had its own implementation. None of them were tested independently.

Middleware fixed all of it in twelve lines.

## The Problem with Page-Level Auth

Page-level auth checks have three problems. They duplicate code across every protected route. They are inconsistent because developers copy-paste and modify slightly. And they run after the route handler starts, meaning some code executes before the auth check happens.

```tsx
// Bad: this pattern in every protected page
export default async function Dashboard() {
  const session = await auth()
  if (!session) redirect('/login') // Runs AFTER the page starts rendering
  
  // ... rest of page
}
```

## Middleware Runs Before Everything

Next.js middleware intercepts requests before they reach your page or API route. It runs at the edge, meaning it executes close to the user with minimal latency.

```ts
// middleware.ts (in project root)
import { auth } from '@/auth'
import { NextResponse } from 'next/server'

export default auth((req) => {
  const isLoggedIn = !!req.auth
  const { pathname } = req.nextUrl
  
  // Public routes — no auth needed
  const isPublic = pathname.startsWith('/login') || 
                   pathname.startsWith('/register') ||
                   pathname.startsWith('/api/webhooks')
  
  if (!isLoggedIn && !isPublic) {
    return NextResponse.redirect(new URL('/login', req.url))
  }
  
  // Admin-only routes
  const isAdmin = pathname.startsWith('/admin')
  if (isAdmin && req.auth?.user?.role !== 'admin') {
    return NextResponse.redirect(new URL('/', req.url))
  }
  
  return NextResponse.next()
})

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

## The Matcher Config

The matcher determines which routes run through middleware. The regex above excludes static files and images. Every other request — pages and API routes — goes through auth verification first.

You can also specify exact matchers:

```ts
export const config = {
  matcher: [
    '/dashboard/:path*',
    '/admin/:path*',
    '/api/protected/:path*',
  ]
}
```

## Role-Based Access in Middleware

For multi-role applications, middleware is the cleanest place to enforce access control:

```ts
const routeRoles: Record<string, string[]> = {
  '/admin': ['admin'],
  '/editor': ['admin', 'editor'],
  '/api/analytics': ['admin'],
}

export default auth((req) => {
  const { pathname } = req.nextUrl
  const userRole = req.auth?.user?.role
  
  for (const [route, roles] of Object.entries(routeRoles)) {
    if (pathname.startsWith(route) && !roles.includes(userRole)) {
      return NextResponse.redirect(new URL('/unauthorized', req.url))
    }
  }
  
  return NextResponse.next()
})
```

## API Route Protection

Middleware works for API routes too:

```ts
// Block unauthenticated API access
if (pathname.startsWith('/api/protected') && !isLoggedIn) {
  return Response.json({ error: 'Unauthorized' }, { status: 401 })
}
```

This returns 401 before any API route handler runs. Your route handlers can assume authentication is already verified.

## Testing Middleware

Extract the auth logic into a testable function:

```ts
// lib/auth-guard.ts
export function checkAccess(
  pathname: string,
  auth: Session | null
): { allowed: boolean; redirect?: string } {
  const isPublic = ['/login', '/register'].some(p => pathname.startsWith(p))
  if (!auth && !isPublic) return { allowed: false, redirect: '/login' }
  
  if (pathname.startsWith('/admin') && auth?.user?.role !== 'admin') {
    return { allowed: false, redirect: '/' }
  }
  
  return { allowed: true }
}
```

Now write unit tests for the auth logic without spinning up a Next.js server.

## Takeaways

- Middleware runs before page and API route handlers — it is the correct layer for auth checks
- One middleware file replaces auth checks in every protected page
- The matcher config controls which routes go through middleware
- Role-based access control belongs in middleware, not individual pages
- Return 401 from middleware for API routes, redirects for pages
- Extract auth logic into testable functions
- Middleware runs at the edge — it adds minimal latency to requests
- Fourteen inconsistent auth checks became twelve lines of centralized logic