We use cookies to enhance your experience on the site
CodeWorlds

Middleware for route protection

In previous modules, we learned the basics of authentication, connecting external providers, session management, and implementation of the role and permission system. Now we will focus on middleware - a powerful Next.js mechanism that allows executing code before reaching the target request, which is an ideal place to implement route protection.

Middleware in Next.js works at the server level but is executed before page rendering or API request handling. This enables centralized access management, redirecting, header modification, and many other operations.

Middleware basics in Next.js

Let's start by understanding how middleware works in Next.js:

  1. Location - middleware is defined in the
    middleware.ts
    file (or
    middleware.js
    ) in the main directory of the Next.js project.
  2. Execution - middleware runs before page rendering or API request handling.
  3. Matching - you can specify for which paths the middleware should be executed.
  4. Action - middleware can redirect the user, modify the request/response, or simply proceed to the next step in the processing chain.

Simple middleware example:

1// middleware.ts
2import { NextResponse } from 'next/server';
3import type { NextRequest } from 'next/server';
4
5export function middleware(request: NextRequest) {
6  console.log('Middleware executed for:', request.nextUrl.pathname);
7  
8  // Continue normal request processing
9  return NextResponse.next();
10}
11
12// Apply middleware only to specific paths
13export const config = {
14  matcher: ['/dashboard/:path*', '/api/:path*'],
15};

Implementing middleware for route protection

Now let's move on to the full implementation of middleware for route protection in a Next.js application.

1. Basic authentication middleware

Let's start with simple middleware that checks if the user is logged in and protects specific paths:

1// middleware.ts
2import { NextResponse } from 'next/server';
3import type { NextRequest } from 'next/server';
4import { getToken } from 'next-auth/jwt';
5
6export async function middleware(request: NextRequest) {
7  // Get the session token from the cookie
8  const token = await getToken({ req: request });
9  
10  // Check if user is logged in
11  const isLoggedIn = !!token;
12  
13  // Current path
14  const { pathname } = request.nextUrl;
15  
16  // Protected paths - requiring login
17  const protectedPaths = [
18    '/dashboard',
19    '/profile',
20    '/settings',
21  ];
22  
23  // Paths only for unauthenticated users (e.g. login page)
24  const authPaths = ['/auth/login', '/auth/register'];
25  
26  // Check if the current path is protected
27  const isProtectedPath = protectedPaths.some(path => 
28    pathname === path || pathname.startsWith(`${path}/`)
29  );
30  
31  // Check if the current path is an authentication page
32  const isAuthPath = authPaths.some(path => pathname === path);
33  
34  // If the user tries to access a protected path but is not logged in
35  if (isProtectedPath && !isLoggedIn) {
36    // Redirect to the login page with information about the target path
37    const url = new URL('/auth/login', request.url);
38    url.searchParams.set('callbackUrl', encodeURI(pathname));
39    return NextResponse.redirect(url);
40  }
41  
42  // If the user is logged in and tries to access the login/registration page
43  if (isAuthPath && isLoggedIn) {
44    // Redirect to the dashboard
45    return NextResponse.redirect(new URL('/dashboard', request.url));
46  }
47  
48  // In other cases, continue normal processing
49  return NextResponse.next();
50}
51
52// Specify for which paths the middleware should run
53export const config = {
54  matcher: [
55    /*
56     * Match all paths except:
57     * 1. Static files (e.g. /favicon.ico, /images/*, etc.)
58     * 2. next-auth API Route (/api/auth/*, which handles authentication itself)
59     */
60    '/((?!api/auth|_next/static|_next/image|favicon.ico).*)',
61  ],
62};

2. Advanced middleware with role and permission control

Let's extend our middleware to also include user roles and permissions:

1// middleware.ts
2import { NextResponse } from 'next/server';
3import type { NextRequest } from 'next/server';
4import { getToken } from 'next-auth/jwt';
5
6// Structure describing access requirements for paths
7interface PathConfig {
8  requireAuth: boolean;        // Whether login is required
9  roles?: string[];            // Allowed roles (if empty, all roles have access)
10  permissions?: string[];      // Required permissions
11}
12
13// Path access configuration
14const pathAccessConfig: Record<string, PathConfig> = {
15  // Administrative paths
16  '/admin': { 
17    requireAuth: true, 
18    roles: ['admin'],
19  },
20  
21  // Moderator paths
22  '/moderator': { 
23    requireAuth: true, 
24    roles: ['admin', 'moderator'],
25  },
26  
27  // Dashboard paths - require login
28  '/dashboard': { 
29    requireAuth: true,
30  },
31  
32  // User management page - requires user:manage permission
33  '/admin/users': { 
34    requireAuth: true, 
35    roles: ['admin'],
36    permissions: ['user:manage'],
37  },
38  
39  // Public paths - available to everyone
40  '/': { 
41    requireAuth: false,
42  },
43  '/about': { 
44    requireAuth: false,
45  },
46  '/contact': { 
47    requireAuth: false,
48  },
49  
50  // Authentication paths - available only to unauthenticated
51  '/auth/login': { 
52    requireAuth: false,
53  },
54  '/auth/register': { 
55    requireAuth: false,
56  },
57};
58
59// Helper function to check whether a path matches a configuration
60function matchPathConfig(pathname: string): [string, PathConfig] | null {
61  // Check exact match
62  if (pathAccessConfig[pathname]) {
63    return [pathname, pathAccessConfig[pathname]];
64  }
65  
66  // Check prefix match
67  for (const configPath in pathAccessConfig) {
68    // Ignore exact matches since we already checked them
69    if (configPath === pathname) continue;
70    
71    // Check if the current path starts with the configured path + /
72    if (pathname.startsWith(`${configPath}/`)) {
73      return [configPath, pathAccessConfig[configPath]];
74    }
75  }
76  
77  // No match
78  return null;
79}
80
81export async function middleware(request: NextRequest) {
82  // Get the session token from the cookie
83  const token = await getToken({ req: request });
84  
85  // Current path
86  const { pathname } = request.nextUrl;
87  
88  // Find access configuration for the current path
89  const pathMatch = matchPathConfig(pathname);
90  
91  // If no configuration was found for this path, continue normal processing
92  if (!pathMatch) {
93    return NextResponse.next();
94  }
95  
96  const [configPath, config] = pathMatch;
97  
98  // Authentication pages - redirect to dashboard if the user is already logged in
99  if (
100    !config.requireAuth && 
101    (pathname === '/auth/login' || pathname === '/auth/register') && 
102    token
103  ) {
104    return NextResponse.redirect(new URL('/dashboard', request.url));
105  }
106  
107  // If the path requires authentication and the user is not logged in
108  if (config.requireAuth && !token) {
109    // Redirect to the login page with information about the target path
110    const url = new URL('/auth/login', request.url);
111    url.searchParams.set('callbackUrl', pathname);
112    return NextResponse.redirect(url);
113  }
114  
115  // If the path requires specific roles, check the user's permissions
116  if (token && config.roles && config.roles.length > 0) {
117    const userRoles = token.role as string;
118    
119    if (!config.roles.includes(userRoles)) {
120      // User does not have the required role - redirect to the no-permissions page
121      return NextResponse.redirect(new URL('/auth/unauthorized', request.url));
122    }
123  }
124  
125  // If the path requires specific permissions, check the user's permissions
126  if (token && config.permissions && config.permissions.length > 0) {
127    const userPermissions = token.permissions as string[] || [];
128    
129    // Check if the user has all required permissions
130    const hasAllPermissions = config.permissions.every(permission =>
131      userPermissions.includes(permission)
132    );
133    
134    if (!hasAllPermissions) {
135      // User does not have the required permissions - redirect to the no-permissions page
136      return NextResponse.redirect(new URL('/auth/unauthorized', request.url));
137    }
138  }
139  
140  // All conditions met - continue
141  return NextResponse.next();
142}
143
144// Match for all paths except static files and authentication API
145export const config = {
146  matcher: [
147    '/((?!api/auth|_next/static|_next/image|images|favicon.ico).*)',
148  ],
149};

3. Middleware for the authentication API

As you can see, the described middleware works for pages, redirecting the user in case of missing permissions. For API routes, we should return appropriate error codes rather than redirecting the user. Here is the middleware implementation specifically for API:

1// middleware.ts
2import { NextResponse } from 'next/server';
3import type { NextRequest } from 'next/server';
4import { getToken } from 'next-auth/jwt';
5
6// API path access configuration
7const apiAccessConfig: Record<string, {
8  roles?: string[];
9  permissions?: string[];
10}> = {
11  // Administrator API
12  '/api/admin': { 
13    roles: ['admin'],
14  },
15  
16  // Moderator API
17  '/api/moderator': { 
18    roles: ['admin', 'moderator'],
19  },
20  
21  // User management API
22  '/api/users': { 
23    permissions: ['user:read'],
24  },
25  '/api/users/create': { 
26    permissions: ['user:create'],
27  },
28  '/api/users/update': { 
29    permissions: ['user:update'],
30  },
31  '/api/users/delete': { 
32    permissions: ['user:delete'],
33  },
34};
35
36// Helper function to check whether an API path matches a configuration
37function matchApiConfig(pathname: string) {
38  // Check exact match
39  if (apiAccessConfig[pathname]) {
40    return [pathname, apiAccessConfig[pathname]];
41  }
42  
43  // Check prefix match
44  for (const configPath in apiAccessConfig) {
45    if (pathname.startsWith(`${configPath}/`)) {
46      return [configPath, apiAccessConfig[configPath]];
47    }
48  }
49  
50  return null;
51}
52
53export async function middleware(request: NextRequest) {
54  // Get the session token
55  const token = await getToken({ req: request });
56  
57  // Current path
58  const { pathname } = request.nextUrl;
59  
60  // Check if this is an API request
61  const isApiRequest = pathname.startsWith('/api/');
62  
63  // If it is not API, proceed to the next middleware
64  if (!isApiRequest) {
65    return NextResponse.next();
66  }
67  
68  // Skip authentication routes
69  if (pathname.startsWith('/api/auth/')) {
70    return NextResponse.next();
71  }
72  
73  // If it is API and the user is not logged in
74  if (!token) {
75    return NextResponse.json(
76      { error: 'Unauthorized - Authentication required' },
77      { status: 401 }
78    );
79  }
80  
81  // Find access configuration for this API endpoint
82  const apiMatch = matchApiConfig(pathname);
83  
84  // If no configuration, we assume being logged in is sufficient
85  if (!apiMatch) {
86    return NextResponse.next();
87  }
88  
89  const [configPath, config] = apiMatch;
90  
91  // Check user role
92  if (config.roles && config.roles.length > 0) {
93    const userRoles = token.role as string;
94    
95    if (!config.roles.includes(userRoles)) {
96      return NextResponse.json(
97        { error: 'Forbidden - Insufficient role permissions' },
98        { status: 403 }
99      );
100    }
101  }
102  
103  // Check user permissions
104  if (config.permissions && config.permissions.length > 0) {
105    const userPermissions = token.permissions as string[] || [];
106    
107    const hasAllPermissions = config.permissions.every(permission =>
108      userPermissions.includes(permission)
109    );
110    
111    if (!hasAllPermissions) {
112      return NextResponse.json(
113        { error: 'Forbidden - Insufficient permissions' },
114        { status: 403 }
115      );
116    }
117  }
118  
119  // All conditions met
120  return NextResponse.next();
121}
122
123// Match for all API routes
124export const config = {
125  matcher: ['/api/:path*'],
126};

4. Combining middleware for pages and API

We can combine both approaches in a single middleware:

1// middleware.ts
2import { NextResponse } from 'next/server';
3import type { NextRequest } from 'next/server';
4import { getToken } from 'next-auth/jwt';
5
6// Page access configuration
7const pageAccessConfig: Record<string, {
8  requireAuth: boolean;
9  roles?: string[];
10  permissions?: string[];
11}> = {
12  '/admin': { requireAuth: true, roles: ['admin'] },
13  '/moderator': { requireAuth: true, roles: ['admin', 'moderator'] },
14  '/dashboard': { requireAuth: true },
15  '/profile': { requireAuth: true },
16  '/settings': { requireAuth: true },
17  '/': { requireAuth: false },
18  '/about': { requireAuth: false },
19  '/auth/login': { requireAuth: false },
20  '/auth/register': { requireAuth: false },
21};
22
23// API access configuration
24const apiAccessConfig: Record<string, {
25  roles?: string[];
26  permissions?: string[];
27}> = {
28  '/api/admin': { roles: ['admin'] },
29  '/api/moderator': { roles: ['admin', 'moderator'] },
30  '/api/users': { permissions: ['user:read'] },
31  '/api/users/create': { permissions: ['user:create'] },
32};
33
34// Helper functions for path matching (omitted for brevity)
35//
36export async function middleware(request: NextRequest) {
37  // Get the session token
38  const token = await getToken({ req: request });
39  const { pathname } = request.nextUrl;
40  
41  // Check if this is an API request
42  const isApiRequest = pathname.startsWith('/api/');
43  
44  // Skip authentication routes
45  if (pathname.startsWith('/api/auth/')) {
46    return NextResponse.next();
47  }
48  
49  // API request handling
50  if (isApiRequest) {
51    // If it is API and the user is not logged in (for protected endpoints)
52    if (!token && !pathname.startsWith('/api/public/')) {
53      return NextResponse.json(
54        { error: 'Unauthorized - Authentication required' },
55        { status: 401 }
56      );
57    }
58    
59    // Match API configuration
60    const apiMatch = matchApiConfig(pathname);
61    if (apiMatch && token) {
62      const [configPath, config] = apiMatch;
63      
64      // Check role and permissions
65      if (!hasRequiredRoles(token, config.roles) || 
66          !hasRequiredPermissions(token, config.permissions)) {
67        return NextResponse.json(
68          { error: 'Forbidden - Insufficient permissions' },
69          { status: 403 }
70        );
71      }
72    }
73  } 
74  // Page handling
75  else {
76    // Match page configuration
77    const pageMatch = matchPageConfig(pathname);
78    if (pageMatch) {
79      const [configPath, config] = pageMatch;
80      
81      // Authentication pages - redirect if the user is logged in
82      if (
83        !config.requireAuth && 
84        (pathname === '/auth/login' || pathname === '/auth/register') && 
85        token
86      ) {
87        return NextResponse.redirect(new URL('/dashboard', request.url));
88      }
89      
90      // If the page requires authentication and the user is not logged in
91      if (config.requireAuth && !token) {
92        const url = new URL('/auth/login', request.url);
93        url.searchParams.set('callbackUrl', pathname);
94        return NextResponse.redirect(url);
95      }
96      
97      // If the user is logged in, check role and permissions
98      if (token && config.requireAuth) {
99        if (!hasRequiredRoles(token, config.roles) || 
100            !hasRequiredPermissions(token, config.permissions)) {
101          return NextResponse.redirect(new URL('/auth/unauthorized', request.url));
102        }
103      }
104    }
105  }
106  
107  // All conditions met
108  return NextResponse.next();
109}
110
111// Helper functions for checking roles and permissions
112function hasRequiredRoles(token: any, requiredRoless?: string[]): boolean {
113  if (!requiredRoless || requiredRoless.length === 0) return true;
114  
115  const userRoles = token.role as string;
116  return requiredRoless.includes(userRoles);
117}
118
119function hasRequiredPermissions(token: any, requiredPermissions?: string[]): boolean {
120  if (!requiredPermissions || requiredPermissions.length === 0) return true;
121  
122  const userPermissions = token.permissions as string[] || [];
123  return requiredPermissions.every(permission => userPermissions.includes(permission));
124}
125
126// Functions matching path configuration (implementation omitted for brevity)
127function matchPageConfig(pathname: string) { /* ... */ }
128function matchApiConfig(pathname: string) { /* ... */ }
129
130export const config = {
131  matcher: [
132    '/((?!_next/static|_next/image|images|favicon.ico).*)',
133  ],
134};

Middleware for handling specific access patterns

Often we need middleware that handles specific use cases. Let's look at some examples:

1. Middleware for request rate limiting (Rate Limiting)

1// middleware.ts
2import { NextResponse } from 'next/server';
3import type { NextRequest } from 'next/server';
4
5// Simple in-memory request limit implementation
6// In production, you should use Redis or a similar solution
7const REQUESTS_PER_MINUTE = 60;
8const WINDOW_SIZE_MS = 60 * 1000; // 1 minute
9
10interface RateLimitState {
11  requests: number;
12  timestamp: number;
13}
14
15const ipRequestCounts = new Map<string, RateLimitState>();
16
17export function middleware(request: NextRequest) {
18  // Get the user's IP address
19  const ip = request.ip || 'unknown';
20  
21  // Check if it is API
22  if (!request.nextUrl.pathname.startsWith('/api/')) {
23    return NextResponse.next();
24  }
25  
26  // Get the current limit state for this IP
27  const now = Date.now();
28  const state = ipRequestCounts.get(ip) || { requests: 0, timestamp: now };
29  
30  // If the time window has passed, reset the counter
31  if (now - state.timestamp > WINDOW_SIZE_MS) {
32    state.requests = 0;
33    state.timestamp = now;
34  }
35  
36  // Increment the request counter
37  state.requests++;
38  ipRequestCounts.set(ip, state);
39  
40  // If the limit is exceeded, return error 429 (Too Many Requests)
41  if (state.requests > REQUESTS_PER_MINUTE) {
42    const response = NextResponse.json(
43      { error: 'Rate limit exceeded. Please try again later.' },
44      { status: 429 }
45    );
46    
47    // Set headers informing about limits
48    response.headers.set('X-RateLimit-Limit', REQUESTS_PER_MINUTE.toString());
49    response.headers.set('X-RateLimit-Remaining', '0');
50    response.headers.set('Retry-After', 
51      Math.ceil((state.timestamp + WINDOW_SIZE_MS - now) / 1000).toString()
52    );
53    
54    return response;
55  }
56  
57  // Within the limit - continue
58  const response = NextResponse.next();
59  
60  // Set headers informing about limits
61  response.headers.set('X-RateLimit-Limit', REQUESTS_PER_MINUTE.toString());
62  response.headers.set(
63    'X-RateLimit-Remaining', 
64    Math.max(0, REQUESTS_PER_MINUTE - state.requests).toString()
65  );
66  
67  return response;
68}
69
70export const config = {
71  matcher: ['/api/:path*'],
72};

2. Middleware for geolocation (Country-based access)

1// middleware.ts
2import { NextResponse } from 'next/server';
3import type { NextRequest } from 'next/server';
4
5// List of allowed country codes
6const ALLOWED_COUNTRIES = ['US', 'CA', 'GB', 'DE', 'FR', 'PL'];
7
8// List of pages subject to geographic restrictions
9const GEO_RESTRICTED_PATHS = [
10  '/premium-content',
11  '/members-only',
12];
13
14export function middleware(request: NextRequest) {
15  // Check if the path is subject to geographic restrictions
16  const isGeoRestrictedPath = GEO_RESTRICTED_PATHS.some(path => 
17    request.nextUrl.pathname === path || 
18    request.nextUrl.pathname.startsWith(`${path}/`)
19  );
20  
21  if (isGeoRestrictedPath) {
22    // Get the user's country information from headers (available on the Vercel platform)
23    const country = request.geo?.country || 'Unknown';
24    
25    // If the user's country is not on the allowed list
26    if (!ALLOWED_COUNTRIES.includes(country)) {
27      return NextResponse.redirect(new URL('/geo-restricted', request.url));
28    }
29  }
30  
31  return NextResponse.next();
32}
33
34export const config = {
35  matcher: [
36    '/premium-content/:path*',
37    '/members-only/:path*',
38  ],
39};

3. Middleware for modifying security headers

1// middleware.ts
2import { NextResponse } from 'next/server';
3import type { NextRequest } from 'next/server';
4
5export function middleware(request: NextRequest) {
6  // Get the original response
7  const response = NextResponse.next();
8  
9  // Add security headers
10  
11  // Protect against XSS attacks
12  response.headers.set('X-XSS-Protection', '1; mode=block');
13  
14  // Prevent MIME type "sniffing"
15  response.headers.set('X-Content-Type-Options', 'nosniff');
16  
17  // Control how the page can be embedded in an iframe
18  response.headers.set('X-Frame-Options', 'SAMEORIGIN');
19  
20  // Content Security Policy - more advanced protection against XSS
21  response.headers.set(
22    'Content-Security-Policy',
23    "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: https:; connect-src 'self' https://api.example.com;"
24  );
25  
26  // HTTP Strict Transport Security - forces HTTPS use
27  response.headers.set(
28    'Strict-Transport-Security',
29    'max-age=31536000; includeSubDomains; preload'
30  );
31  
32  // Permissions Policy - declare which browser features are allowed
33  response.headers.set(
34    'Permissions-Policy',
35    'camera=(), microphone=(), geolocation=(self), interest-cohort=()'
36  );
37  
38  return response;
39}
40
41export const config = {
42  matcher: [
43    '/((?!_next/static|_next/image|images|favicon.ico).*)',
44  ],
45};

4. Middleware for A/B testing

1// middleware.ts
2import { NextResponse } from 'next/server';
3import type { NextRequest } from 'next/server';
4
5// A/B experiment configuration
6const AB_TESTS = {
7  'new-landing-page': {
8    variants: ['control', 'variant-a', 'variant-b'],
9    weights: [0.34, 0.33, 0.33], // Traffic split
10    paths: ['/'],  // Paths covered by the test
11    cookieName: 'ab-new-landing',
12  },
13  'checkout-flow': {
14    variants: ['original', 'simplified'],
15    weights: [0.5, 0.5],
16    paths: ['/checkout', '/cart'],
17    cookieName: 'ab-checkout',
18  },
19};
20
21export function middleware(request: NextRequest) {
22  const url = request.nextUrl;
23  const response = NextResponse.next();
24  
25  // Iterate through the defined tests
26  for (const [testName, test] of Object.entries(AB_TESTS)) {
27    // Check if the current path is covered by the test
28    const isTestPath = test.paths.some(path => 
29      url.pathname === path || url.pathname.startsWith(`${path}/`)
30    );
31    
32    if (isTestPath) {
33      // Check if the user already has a test variant assigned
34      const existingVariant = request.cookies.get(test.cookieName)?.value;
35      let variant = existingVariant;
36      
37      // If the user has no assigned variant, assign a new one
38      if (!variant) {
39        variant = assignVariant(test.variants, test.weights);
40        
41        // Set a cookie with the assigned variant
42        response.cookies.set(test.cookieName, variant, {
43          maxAge: 60 * 60 * 24 * 30, // 30 days
44          path: '/',
45        });
46      }
47      
48      // Add a header with test variant information
49      response.headers.set(`X-AB-Test-${testName}`, variant);
50      
51      // You can also set a rewrite to a different page for different variants
52      if (testName === 'new-landing-page' && url.pathname === '/') {
53        if (variant === 'variant-a') {
54          // Internal redirect (invisible to the user)
55          return NextResponse.rewrite(new URL('/landing-a', request.url));
56        } else if (variant === 'variant-b') {
57          return NextResponse.rewrite(new URL('/landing-b', request.url));
58        }
59      }
60    }
61  }
62  
63  return response;
64}
65
66// Function that assigns a variant based on weights
67function assignVariant(variants: string[], weights: number[]): string {
68  const randomNum = Math.random();
69  let cumulativeWeight = 0;
70  
71  for (let i = 0; i < variants.length; i++) {
72    cumulativeWeight += weights[i];
73    if (randomNum < cumulativeWeight) {
74      return variants[i];
75    }
76  }
77  
78  // Fall back to the first variant
79  return variants[0];
80}
81
82export const config = {
83  matcher: [
84    '/', '/checkout', '/cart',
85    '/landing-a', '/landing-b',
86  ],
87};

Testing middleware

Tests are very important to verify that middleware works correctly, especially since it handles key application security aspects. Here is a simple example of unit tests:

1// __tests__/middleware.test.ts
2import { NextRequest } from 'next/server';
3import { middleware } from '../middleware';
4import { getToken } from 'next-auth/jwt';
5
6// We mock getToken from next-auth/jwt
7jest.mock('next-auth/jwt', () => ({
8  getToken: jest.fn(),
9}));
10
11describe('Auth Middleware', () => {
12  // Setup and reset before each test
13  beforeEach(() => {
14    jest.clearAllMocks();
15  });
16  
17  it('should redirect unauthenticated user from protected route to login page', async () => {
18    // Mock getToken returning null (unauthenticated user)
19    (getToken as jest.Mock).mockResolvedValueOnce(null);
20    
21    // Create a test request to a protected path
22    const req = new NextRequest(new URL('http://localhost:3000/dashboard'));
23    
24    // Run the middleware
25    const response = await middleware(req);
26    
27    // Check that it redirects to the login page
28    expect(response).not.toBeNull();
29    expect(response.status).toBe(307); // Kod przekierowania
30    expect(response.headers.get('location')).toBe('http://localhost:3000/auth/login?callbackUrl=%2Fdashboard');
31  });
32  
33  it('should allow authenticated user to access protected route', async () => {
34    // Mock getToken returning a token (logged-in user)
35    (getToken as jest.Mock).mockResolvedValueOnce({
36      name: 'Test User',
37      email: 'test@example.com',
38      role: 'user',
39      permissions: ['content:read']
40    });
41    
42    // Create a test request to a protected path
43    const req = new NextRequest(new URL('http://localhost:3000/dashboard'));
44    
45    // Run the middleware
46    const response = await middleware(req);
47    
48    // Check that it allows access
49    expect(response).not.toBeNull();
50    expect(response.status).toBe(200);
51  });
52  
53  it('should redirect authenticated user from login page to dashboard', async () => {
54    // Mock getToken returning a token (logged-in user)
55    (getToken as jest.Mock).mockResolvedValueOnce({
56      name: 'Test User',
57      email: 'test@example.com',
58    });
59    
60    // Create a test request to the login page
61    const req = new NextRequest(new URL('http://localhost:3000/auth/login'));
62    
63    // Run the middleware
64    const response = await middleware(req);
65    
66    // Check that it redirects to the dashboard
67    expect(response).not.toBeNull();
68    expect(response.status).toBe(307);
69    expect(response.headers.get('location')).toBe('http://localhost:3000/dashboard');
70  });
71  
72  it('should redirect user without admin role from admin page', async () => {
73    // Mock getToken returning a token with user role
74    (getToken as jest.Mock).mockResolvedValueOnce({
75      name: 'Test User',
76      email: 'test@example.com',
77      role: 'user',
78      permissions: ['content:read']
79    });
80    
81    // Create a test request to the admin page
82    const req = new NextRequest(new URL('http://localhost:3000/admin/dashboard'));
83    
84    // Run the middleware
85    const response = await middleware(req);
86    
87    // Check that it redirects to the no-permissions page
88    expect(response).not.toBeNull();
89    expect(response.status).toBe(307);
90    expect(response.headers.get('location')).toBe('http://localhost:3000/auth/unauthorized');
91  });
92});

Best practices and patterns

1. Performance optimization middleware

Middleware is executed on every request, so it's important that it's as efficient as possible:

  • Limit external API calls - Middleware should be fast. Avoid API calls in middleware when possible.
  • Use caching - If you must fetch data, consider caching it.
  • Define matcher precisely - Limit middleware execution only to paths that need it.

2. Debugging middleware

Debugging middleware can be difficult because it runs "behind the scenes":

  • Add logs - Use
    console.log()
    to trace middleware activity.
  • Add debug headers - You can add custom response headers to track what is happening.
1// Example of adding debug headers
2const response = NextResponse.next();
3response.headers.set('X-Debug-Path', request.nextUrl.pathname);
4response.headers.set('X-Debug-Auth', token ? 'Authenticated' : 'Unauthenticated');
5return response;

3. Organizing middleware code

For complex applications, it's worth considering middleware modularization:

1// middleware/auth.ts
2import { NextRequest, NextResponse } from 'next/server';
3import { getToken } from 'next-auth/jwt';
4
5export async function authMiddleware(req: NextRequest) {
6  // Authentication middleware implementation
7  //
8}
9
10// middleware/security.ts
11import { NextRequest, NextResponse } from 'next/server';
12
13export function securityHeadersMiddleware(req: NextRequest) {
14  // Security headers middleware implementation
15  //
16}
17
18// middleware.ts
19import { NextRequest, NextResponse } from 'next/server';
20import { authMiddleware } from './middleware/auth';
21import { securityHeadersMiddleware } from './middleware/security';
22
23export async function middleware(req: NextRequest) {
24  // Check if the path requires authentication
25  if (req.nextUrl.pathname.startsWith('/dashboard') || 
26      req.nextUrl.pathname.startsWith('/admin')) {
27    return await authMiddleware(req);
28  }
29  
30  // Add security headers to all responses
31  return securityHeadersMiddleware(req);
32}
33
34export const config = { /* ... */ };

Summary

Middleware in Next.js offers a powerful mechanism for implementing route protection and many other server-level features. In this module we learned:

  1. Middleware basics in Next.js - how it works and how to configure it
  2. Authentication middleware implementation - how to protect routes from unauthorized access
  3. Middleware with role and permission control - how to provide access based on user roles
  4. Specialized middleware - how to implement rate limiting, geolocation, security headers, and A/B tests
  5. Middleware testing - how to write tests for middleware
  6. Best practices and patterns - how to optimize and organize middleware code

A good middleware implementation is a key element of Next.js application security and performance. Thanks to the techniques presented in this module, you can create advanced, multi-layered protections for your applications.

In the next module, we will move on to implementing a Context Provider for authentication, which will enable easy access to authentication data throughout the application.

Go to CodeWorlds