Skip to content

Next.js Middleware

Middleware is a powerful feature of Next.js that allows you to run code before a request is completed. This chapter will detail how to use middleware for authentication, redirects, internationalization, and other common scenarios.

Middleware Basics

Creating Middleware

Create a middleware.ts file in the project root directory:

typescript
// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

export function middleware(request: NextRequest) {
  // Middleware logic
  console.log('Middleware executed:', request.nextUrl.pathname)
  
  // Continue processing request
  return NextResponse.next()
}

// Configure matching paths
export const config = {
  matcher: [
    /*
     * Match all paths except:
     * - api (API routes)
     * - _next/static (static files)
     * - _next/image (image optimization files)
     * - favicon.ico (favicon file)
     */
    '/((?!api|_next/static|_next/image|favicon.ico).*)',
  ],
}

Basic Response Types

typescript
// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

export function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl

  // 1. Continue processing request
  if (pathname === '/continue') {
    return NextResponse.next()
  }

  // 2. Redirect
  if (pathname === '/old-page') {
    return NextResponse.redirect(new URL('/new-page', request.url))
  }

  // 3. Rewrite URL
  if (pathname === '/rewrite') {
    return NextResponse.rewrite(new URL('/internal-page', request.url))
  }

  // 4. Return response
  if (pathname === '/api/blocked') {
    return new NextResponse('Access Denied', { status: 403 })
  }

  return NextResponse.next()
}

Authentication Middleware

JWT Authentication

typescript
// 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 (error) {
    return null
  }
}

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

  // Paths requiring authentication
  const protectedPaths = ['/dashboard', '/profile', '/admin']
  const isProtectedPath = protectedPaths.some(path => 
    pathname.startsWith(path)
  )

  if (isProtectedPath) {
    const token = request.cookies.get('token')?.value

    if (!token) {
      // Redirect to login page
      const loginUrl = new URL('/login', request.url)
      loginUrl.searchParams.set('redirect', pathname)
      return NextResponse.redirect(loginUrl)
    }

    const user = await verifyToken(token)
    if (!user) {
      // Invalid token, clear cookie and redirect
      const response = NextResponse.redirect(new URL('/login', request.url))
      response.cookies.delete('token')
      return response
    }

    // Add user info to request headers
    const response = NextResponse.next()
    response.headers.set('x-user-id', user.sub as string)
    response.headers.set('x-user-role', user.role as string)
    return response
  }

  return NextResponse.next()
}

export const config = {
  matcher: ['/dashboard/:path*', '/profile/:path*', '/admin/:path*']
}

Role-Based Access Control

typescript
// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

interface User {
  id: string
  role: 'admin' | 'user' | 'moderator'
}

async function getUserFromToken(token: string): Promise<User | null> {
  // Implement token verification logic
  try {
    const response = await fetch(`${process.env.API_URL}/auth/verify`, {
      headers: { Authorization: `Bearer ${token}` }
    })
    
    if (response.ok) {
      return await response.json()
    }
  } catch (error) {
    console.error('Token verification failed:', error)
  }
  
  return null
}

export async function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl
  
  // Define path permissions
  const pathPermissions = {
    '/admin': ['admin'],
    '/moderator': ['admin', 'moderator'],
    '/dashboard': ['admin', 'moderator', 'user'],
  }

  const requiredRoles = Object.entries(pathPermissions).find(([path]) =>
    pathname.startsWith(path)
  )?.[1]

  if (requiredRoles) {
    const token = request.cookies.get('auth-token')?.value

    if (!token) {
      return NextResponse.redirect(new URL('/login', request.url))
    }

    const user = await getUserFromToken(token)
    
    if (!user || !requiredRoles.includes(user.role)) {
      return NextResponse.redirect(new URL('/unauthorized', request.url))
    }

    // Add user info to request headers
    const response = NextResponse.next()
    response.headers.set('x-user-id', user.id)
    response.headers.set('x-user-role', user.role)
    return response
  }

  return NextResponse.next()
}

Internationalization Middleware

Language Detection and Redirect

typescript
// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

const locales = ['en', 'zh', 'ja', 'ko']
const defaultLocale = 'en'

function getLocale(request: NextRequest): string {
  // 1. Check language in URL path
  const pathname = request.nextUrl.pathname
  const pathnameLocale = locales.find(
    locale => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`
  )
  
  if (pathnameLocale) return pathnameLocale

  // 2. Check cookie
  const cookieLocale = request.cookies.get('locale')?.value
  if (cookieLocale && locales.includes(cookieLocale)) {
    return cookieLocale
  }

  // 3. Check Accept-Language header
  const acceptLanguage = request.headers.get('accept-language')
  if (acceptLanguage) {
    const browserLocale = acceptLanguage
      .split(',')[0]
      .split('-')[0]
    
    if (locales.includes(browserLocale)) {
      return browserLocale
    }
  }

  return defaultLocale
}

export function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl

  // Check if path already contains language prefix
  const pathnameHasLocale = locales.some(
    locale => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`
  )

  if (!pathnameHasLocale) {
    const locale = getLocale(request)
    const newUrl = new URL(`/${locale}${pathname}`, request.url)
    
    const response = NextResponse.redirect(newUrl)
    response.cookies.set('locale', locale, { maxAge: 60 * 60 * 24 * 365 })
    return response
  }

  return NextResponse.next()
}

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

Language Switcher

typescript
// components/LanguageSwitcher.tsx
'use client'

import { useRouter, usePathname } from 'next/navigation'
import { useState } from 'react'

const languages = {
  en: 'English',
  zh: '中文',
  ja: '日本語',
  ko: '한국어',
}

export default function LanguageSwitcher() {
  const router = useRouter()
  const pathname = usePathname()
  const [currentLocale, setCurrentLocale] = useState('en')

  const switchLanguage = (newLocale: string) => {
    // Remove current language prefix
    const pathWithoutLocale = pathname.replace(/^\/[a-z]{2}/, '')
    
    // Add new language prefix
    const newPath = `/${newLocale}${pathWithoutLocale}`
    
    // Set cookie
    document.cookie = `locale=${newLocale}; path=/; max-age=${60 * 60 * 24 * 365}`
    
    setCurrentLocale(newLocale)
    router.push(newPath)
  }

  return (
    <select
      value={currentLocale}
      onChange={(e) => switchLanguage(e.target.value)}
      className="border rounded px-2 py-1"
    >
      {Object.entries(languages).map(([code, name]) => (
        <option key={code} value={code}>
          {name}
        </option>
      ))}
    </select>
  )
}

Security Middleware

CSRF Protection

typescript
// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import crypto from 'crypto'

function generateCSRFToken(): string {
  return crypto.randomBytes(32).toString('hex')
}

function verifyCSRFToken(token: string, sessionToken: string): boolean {
  return token === sessionToken
}

export function middleware(request: NextRequest) {
  const { method, nextUrl } = request

  // Only check CSRF for state-changing requests
  if (['POST', 'PUT', 'DELETE', 'PATCH'].includes(method)) {
    const csrfToken = request.headers.get('x-csrf-token')
    const sessionToken = request.cookies.get('csrf-token')?.value

    if (!csrfToken || !sessionToken || !verifyCSRFToken(csrfToken, sessionToken)) {
      return new NextResponse('CSRF token mismatch', { status: 403 })
    }
  }

  // Generate CSRF token for GET requests
  if (method === 'GET' && !request.cookies.get('csrf-token')) {
    const token = generateCSRFToken()
    const response = NextResponse.next()
    response.cookies.set('csrf-token', token, {
      httpOnly: true,
      secure: process.env.NODE_ENV === 'production',
      sameSite: 'strict',
    })
    return response
  }

  return NextResponse.next()
}

Rate Limiting

typescript
// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

// Simple in-memory storage (use Redis in production)
const rateLimitMap = new Map<string, { count: number; resetTime: number }>()

function getRateLimitKey(request: NextRequest): string {
  // Use IP address as limit key
  const forwarded = request.headers.get('x-forwarded-for')
  const ip = forwarded ? forwarded.split(',')[0] : request.ip || 'unknown'
  return ip
}

function isRateLimited(key: string, limit: number, windowMs: number): boolean {
  const now = Date.now()
  const record = rateLimitMap.get(key)

  if (!record || now > record.resetTime) {
    // Reset or create new record
    rateLimitMap.set(key, {
      count: 1,
      resetTime: now + windowMs
    })
    return false
  }

  if (record.count >= limit) {
    return true
  }

  record.count++
  return false
}

export function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl

  // Apply rate limiting to API routes
  if (pathname.startsWith('/api/')) {
    const key = getRateLimitKey(request)
    const limit = 100 // 100 requests per minute
    const windowMs = 60 * 1000 // 1 minute

    if (isRateLimited(key, limit, windowMs)) {
      return new NextResponse('Too Many Requests', {
        status: 429,
        headers: {
          'Retry-After': '60'
        }
      })
    }
  }

  return NextResponse.next()
}

Redirects and Rewrites

Dynamic Redirects

typescript
// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

// Redirect rules
const redirectRules = [
  { from: '/old-blog/:slug*', to: '/blog/:slug*' },
  { from: '/products/:id', to: '/shop/products/:id' },
  { from: '/user/:id', to: '/profile/:id' },
]

function applyRedirectRules(pathname: string): string | null {
  for (const rule of redirectRules) {
    const fromPattern = rule.from.replace(/:\w+\*/g, '(.*)').replace(/:\w+/g, '([^/]+)')
    const regex = new RegExp(`^${fromPattern}$`)
    const match = pathname.match(regex)

    if (match) {
      let to = rule.to
      match.slice(1).forEach((param, index) => {
        to = to.replace(/:\w+\*?/, param)
      })
      return to
    }
  }

  return null
}

export function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl

  // Apply redirect rules
  const redirectTo = applyRedirectRules(pathname)
  if (redirectTo) {
    return NextResponse.redirect(new URL(redirectTo, request.url))
  }

  return NextResponse.next()
}

A/B Testing

typescript
// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

function getVariant(request: NextRequest): 'A' | 'B' {
  // Check existing cookie
  const existingVariant = request.cookies.get('ab-test-variant')?.value
  if (existingVariant === 'A' || existingVariant === 'B') {
    return existingVariant
  }

  // Randomly assign variant
  return Math.random() < 0.5 ? 'A' : 'B'
}

export function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl

  // Only A/B test the homepage
  if (pathname === '/') {
    const variant = getVariant(request)
    
    let response: NextResponse

    if (variant === 'B') {
      // Rewrite to variant B page
      response = NextResponse.rewrite(new URL('/home-variant-b', request.url))
    } else {
      response = NextResponse.next()
    }

    // Set variant cookie
    response.cookies.set('ab-test-variant', variant, {
      maxAge: 60 * 60 * 24 * 30, // 30 days
    })

    return response
  }

  return NextResponse.next()
}

Logging and Monitoring

Request Logging

typescript
// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

interface RequestLog {
  timestamp: string
  method: string
  url: string
  userAgent: string
  ip: string
  duration?: number
}

function logRequest(log: RequestLog) {
  // Send to logging service
  console.log(JSON.stringify(log))
  
  // Or send to external service
  // fetch('/api/logs', {
  //   method: 'POST',
  //   body: JSON.stringify(log)
  // })
}

export function middleware(request: NextRequest) {
  const startTime = Date.now()
  
  const log: RequestLog = {
    timestamp: new Date().toISOString(),
    method: request.method,
    url: request.url,
    userAgent: request.headers.get('user-agent') || '',
    ip: request.headers.get('x-forwarded-for') || request.ip || 'unknown',
  }

  const response = NextResponse.next()

  // Add response time
  response.headers.set('x-response-time', `${Date.now() - startTime}ms`)

  // Log asynchronously
  Promise.resolve().then(() => {
    log.duration = Date.now() - startTime
    logRequest(log)
  })

  return response
}

Performance Monitoring

typescript
// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

const performanceMetrics = new Map<string, number[]>()

function recordMetric(path: string, duration: number) {
  if (!performanceMetrics.has(path)) {
    performanceMetrics.set(path, [])
  }
  
  const metrics = performanceMetrics.get(path)!
  metrics.push(duration)
  
  // Keep only the last 100 records
  if (metrics.length > 100) {
    metrics.shift()
  }
}

function getAverageResponseTime(path: string): number {
  const metrics = performanceMetrics.get(path)
  if (!metrics || metrics.length === 0) return 0
  
  const sum = metrics.reduce((a, b) => a + b, 0)
  return sum / metrics.length
}

export function middleware(request: NextRequest) {
  const startTime = Date.now()
  const { pathname } = request.nextUrl

  const response = NextResponse.next()

  // Record response time
  Promise.resolve().then(() => {
    const duration = Date.now() - startTime
    recordMetric(pathname, duration)
    
    // Send warning if response time is too long
    if (duration > 1000) {
      console.warn(`Slow response: ${pathname} took ${duration}ms`)
    }
  })

  return response
}

Error Handling

Middleware Error Handling

typescript
// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

export function middleware(request: NextRequest) {
  try {
    // Middleware logic
    const { pathname } = request.nextUrl

    // Operation that might throw an error
    if (pathname === '/error-test') {
      throw new Error('Test error')
    }

    return NextResponse.next()
  } catch (error) {
    console.error('Middleware error:', error)
    
    // Return error response
    return new NextResponse('Internal Server Error', {
      status: 500,
      headers: {
        'Content-Type': 'text/plain',
      },
    })
  }
}

Testing Middleware

Unit Testing

typescript
// __tests__/middleware.test.ts
import { NextRequest } from 'next/server'
import { middleware } from '../middleware'

describe('Middleware', () => {
  it('should redirect unauthenticated users', async () => {
    const request = new NextRequest('http://localhost:3000/dashboard')
    const response = await middleware(request)
    
    expect(response.status).toBe(307) // Redirect status code
    expect(response.headers.get('location')).toContain('/login')
  })

  it('should allow authenticated users', async () => {
    const request = new NextRequest('http://localhost:3000/dashboard', {
      headers: {
        cookie: 'token=valid-jwt-token'
      }
    })
    
    const response = await middleware(request)
    expect(response.status).toBe(200)
  })
})

Best Practices

1. Performance Optimization

typescript
// ✅ Good practice - early return
export function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl
  
  // Skip static resources
  if (pathname.startsWith('/_next/') || pathname.includes('.')) {
    return NextResponse.next()
  }
  
  // Other logic...
}

// ❌ Avoid - complex synchronous operations
export function middleware(request: NextRequest) {
  // Avoid complex computations
  const result = heavyComputation()
  return NextResponse.next()
}

2. Error Handling

typescript
// ✅ Good practice
export async function middleware(request: NextRequest) {
  try {
    const user = await verifyToken(token)
    return NextResponse.next()
  } catch (error) {
    console.error('Auth error:', error)
    return NextResponse.redirect(new URL('/login', request.url))
  }
}

3. Matcher Configuration

typescript
// ✅ Good practice - precise matching
export const config = {
  matcher: [
    '/dashboard/:path*',
    '/api/protected/:path*',
    '/((?!_next/static|_next/image|favicon.ico).*)',
  ],
}

// ❌ Avoid - overly broad matching
export const config = {
  matcher: '/:path*', // Matches all paths
}

Summary

Key features of Next.js middleware:

  • Request interception - Execute logic before request reaches the page
  • Flexible responses - Redirect, rewrite, modify responses
  • Performance optimization - Edge computing, reduced server load
  • Security enhancement - Authentication, authorization, CSRF protection
  • Monitoring and analytics - Request logging, performance metrics

Using middleware effectively can improve your application's security, performance, and user experience.

Content is for learning and research only.