Next.js Middleware: Use Cases and Implementation

Abhiraj GhoshAbhiraj Ghosh
10 min read

Introduction

Next.js has revolutionized React development by providing a powerful framework with built-in features for routing, server-side rendering, and API development. One of its most powerful yet sometimes overlooked features is Middleware - code that runs before a request is completed. Middleware sits between the client and your application, allowing you to modify responses, redirect users, rewrite URLs, or add headers before the page or API route is rendered.

In this comprehensive guide, we'll explore what Next.js Middleware is, dive into five powerful use cases, and walk through implementation examples for each scenario.

What is Next.js Middleware?

Introduced in Next.js 12, Middleware allows developers to run code before a request is completed. It executes on the Edge runtime, which means it runs close to your users, providing faster response times compared to traditional server-side operations.

Middleware runs before rendering any route in your project, giving you the power to:

  • Modify the response by rewriting, redirecting, or adding headers

  • Make decisions based on incoming request data like cookies, headers, or URL path

  • Execute authentication or authorization logic early in the request lifecycle

  • Implement custom logging, monitoring, or analytics.

Setting Up Middleware in Next.js

Before we dive into specific use cases, let's understand the basic setup. In Next.js, middleware is defined in a file named middleware.ts (or .js) in the root of your project:

import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

export function middleware(request: NextRequest) {
  // Your middleware logic here
  return NextResponse.next();
}

// Optional: Configure which routes use this middleware
export const config = {
  matcher: '/api/:path*',
};

With this basic understanding, let's explore five powerful use cases for Next.js Middleware.

Use Case 1: Authentication and Authorization

One of the most common uses for middleware is to protect routes based on authentication status or user roles.

Implementation Example:

import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

export function middleware(request: NextRequest) {
  // Get the pathname of the request
  const path = request.nextUrl.pathname;

  // Get authentication token from cookie
  const token = request.cookies.get('authToken')?.value;

  // Define paths that are considered public
  const publicPaths = ['/', '/login', '/register', '/api/auth'];

  // Check if the path is protected and user is not authenticated
  const isProtectedRoute = !publicPaths.some(pp => path === pp || path.startsWith(pp));

  if (isProtectedRoute && !token) {
    // Redirect to login page if trying to access a protected route without authentication
    const url = new URL('/login', request.url);
    url.searchParams.set('from', path);
    return NextResponse.redirect(url);
  }

  // For dashboard routes, check user role (stored in a separate cookie)
  if (path.startsWith('/admin')) {
    const userRole = request.cookies.get('userRole')?.value;

    if (userRole !== 'admin') {
      // Redirect unauthorized users to the dashboard
      return NextResponse.redirect(new URL('/dashboard', request.url));
    }
  }

  return NextResponse.next();
}

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

This middleware checks if a user is authenticated before allowing access to protected routes, and further validates admin privileges for the admin section.

Use Case 2: Localization and i18n

Middleware is perfect for handling internationalization by detecting the user's language preference and redirecting them to the appropriate localized version of your website.

Implementation Example:

import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

// Define supported languages
const supportedLocales = ['en', 'es', 'fr', 'de', 'ja'];
const defaultLocale = 'en';

export function middleware(request: NextRequest) {
  // Get the pathname of the request
  const { pathname } = request.nextUrl;

  // Check if the pathname already has a locale
  const pathnameHasLocale = supportedLocales.some(
    (locale) => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`
  );

  if (pathnameHasLocale) return NextResponse.next();

  // If no locale in path, detect from cookie or Accept-Language header
  let locale: string;

  // Check for saved user preference
  const savedLocale = request.cookies.get('NEXT_LOCALE')?.value;
  if (savedLocale && supportedLocales.includes(savedLocale)) {
    locale = savedLocale;
  } else {
    // Try to get locale from Accept-Language header
    const acceptLanguage = request.headers.get('Accept-Language');
    if (acceptLanguage) {
      locale = acceptLanguage
        .split(',')
        .map(lang => lang.split(';')[0].trim().substring(0, 2))
        .find(lang => supportedLocales.includes(lang)) || defaultLocale;
    } else {
      locale = defaultLocale;
    }
  }

  // Redirect to the same URL but with locale prefix
  const newUrl = new URL(`/${locale}${pathname === '/' ? '' : pathname}`, request.url);

  // Preserve query parameters
  request.nextUrl.searchParams.forEach((value, key) => {
    newUrl.searchParams.set(key, value);
  });

  return NextResponse.redirect(newUrl);
}

export const config = {
  matcher: [
    // Match all routes except those starting with a locale
    '/((?!api|_next/static|_next/image|favicon.ico|en|es|fr|de|ja).*)',
  ],
};

This middleware checks whether the URL includes a locale prefix. If not, it determines the appropriate language based on user preferences or browser settings and redirects accordingly.

Use Case 3: A/B Testing and Feature Flags

Middleware provides an excellent way to implement A/B testing or feature flags by serving different versions of your application to different users.

Implementation Example:

import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

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

  // Only apply A/B testing to the home page
  if (pathname !== '/') return NextResponse.next();

  // Check for existing test group assignment
  const testGroup = request.cookies.get('ab_test_group')?.value;

  if (!testGroup) {
    // Assign user to a test group if they don't have one already
    const response = NextResponse.next();

    // Randomly assign to group A or B
    const newTestGroup = Math.random() < 0.5 ? 'A' : 'B';

    // Set cookie with the test group - secure and HTTP only
    response.cookies.set({
      name: 'ab_test_group',
      value: newTestGroup,
      httpOnly: true,
      secure: process.env.NODE_ENV === 'production',
      maxAge: 60 * 60 * 24 * 7, // 7 days
      path: '/',
    });

    if (newTestGroup === 'B') {
      // Rewrite to the B variant of the home page
      return NextResponse.rewrite(new URL('/experiments/home-v2', request.url));
    }

    return response;
  } else if (testGroup === 'B') {
    // If user is already in group B, rewrite to alternative version
    return NextResponse.rewrite(new URL('/experiments/home-v2', request.url));
  }

  // Group A users see the default version
  return NextResponse.next();
}

// Only run middleware on the home page
export const config = {
  matcher: '/',
};

This implementation randomly assigns visitors to one of two groups and serves a different version of the homepage based on their assignment. The assignment is stored in a cookie for consistency across visits.

Use Case 4: Request Logging and Analytics

Middleware can be used to log information about requests for analytics purposes without affecting the actual response.

Implementation Example:

import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

export function middleware(request: NextRequest) {
  // Get current timestamp
  const requestStartTime = Date.now();

  // Extract useful information from the request
  const { pathname, search } = request.nextUrl;
  const userAgent = request.headers.get('user-agent') || 'unknown';
  const referer = request.headers.get('referer') || 'direct';
  const ip = request.ip || 'unknown';

  // Generate a unique request ID
  const requestId = crypto.randomUUID();

  // Log the incoming request
  console.log(JSON.stringify({
    timestamp: new Date().toISOString(),
    requestId,
    method: request.method,
    url: `${pathname}${search}`,
    userAgent,
    referer,
    ip,
    country: request.geo?.country || 'unknown',
  }));

  // Pass request through to application
  const response = NextResponse.next();

  // Add the request ID as a header to correlate logs
  response.headers.set('X-Request-ID', requestId);

  // Calculate response time and log it when the response is ready
  response.headers.set('Server-Timing', `request;dur=${Date.now() - requestStartTime}`);

  return response;
}

export const config = {
  matcher: [
    /*
     * Match all request paths except for API health check and static assets
     */
    '/((?!_next/static|_next/image|favicon.ico|api/health).*)',
  ],
};

This middleware logs detailed information about each request, including geographic data if available, and adds response timing information via the Server-Timing header.

Use Case 5: Rate Limiting and API Protection

Protecting your API routes from abuse is crucial. Middleware can implement rate limiting to prevent excessive requests from a single client.

Implementation Example:

import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

// Simple in-memory store for rate limiting
// Note: In production, use Redis or a similar external store
const rateLimitStore = new Map<string, { count: number, timestamp: number }>();

// Configure rate limits
const RATE_LIMIT_MAX = 60; // Maximum requests
const RATE_LIMIT_WINDOW = 60 * 1000; // Time window in milliseconds (1 minute)

export function middleware(request: NextRequest) {
  // Only apply rate limiting to API routes
  if (!request.nextUrl.pathname.startsWith('/api/')) {
    return NextResponse.next();
  }

  // Determine the client identifier (IP address or token)
  const clientId = request.ip || request.headers.get('x-forwarded-for') || 'unknown-client';

  // Get current timestamp
  const now = Date.now();

  // Check if client exists in the store
  const clientData = rateLimitStore.get(clientId);

  if (!clientData || (now - clientData.timestamp) > RATE_LIMIT_WINDOW) {
    // First request or window expired, reset the counter
    rateLimitStore.set(clientId, { count: 1, timestamp: now });

    // Add rate limit headers to the response
    const response = NextResponse.next();
    response.headers.set('X-RateLimit-Limit', RATE_LIMIT_MAX.toString());
    response.headers.set('X-RateLimit-Remaining', (RATE_LIMIT_MAX - 1).toString());
    response.headers.set('X-RateLimit-Reset', (now + RATE_LIMIT_WINDOW).toString());

    return response;
  } else if (clientData.count < RATE_LIMIT_MAX) {
    // Increment counter for existing client
    rateLimitStore.set(clientId, { 
      count: clientData.count + 1, 
      timestamp: clientData.timestamp 
    });

    // Add rate limit headers to the response
    const response = NextResponse.next();
    response.headers.set('X-RateLimit-Limit', RATE_LIMIT_MAX.toString());
    response.headers.set('X-RateLimit-Remaining', (RATE_LIMIT_MAX - clientData.count - 1).toString());
    response.headers.set('X-RateLimit-Reset', (clientData.timestamp + RATE_LIMIT_WINDOW).toString());

    return response;
  } else {
    // Rate limit exceeded
    return new NextResponse(JSON.stringify({
      error: 'Too many requests',
      message: 'Rate limit exceeded. Please try again later.'
    }), {
      status: 429,
      headers: {
        'Content-Type': 'application/json',
        'X-RateLimit-Limit': RATE_LIMIT_MAX.toString(),
        'X-RateLimit-Remaining': '0',
        'X-RateLimit-Reset': (clientData.timestamp + RATE_LIMIT_WINDOW).toString(),
        'Retry-After': Math.ceil((clientData.timestamp + RATE_LIMIT_WINDOW - now) / 1000).toString()
      }
    });
  }
}

export const config = {
  matcher: '/api/:path*',
};

This middleware implements a simple rate limiter that tracks requests per client IP and returns a 429 Too Many Requests response when limits are exceeded. For production use, you would want to use a more scalable storage solution like Redis.

Bonus Use Case: Edge Caching with Stale-While-Revalidate

Next.js Middleware can implement advanced caching strategies like stale-while-revalidate (SWR) at the edge.

Implementation Example:

import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

// Configure which paths should be cached
const CACHEABLE_PATHS = [
  '/blog',
  '/products',
  '/documentation',
];

// Cache duration
const CACHE_MAX_AGE = 60; // 1 minute
const STALE_WHILE_REVALIDATE = 86400; // 1 day

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

  // Only apply caching to specific paths
  const isCacheablePath = CACHEABLE_PATHS.some(path => 
    pathname === path || pathname.startsWith(`${path}/`)
  );

  if (!isCacheablePath) return NextResponse.next();

  // Skip caching for authenticated requests
  if (request.cookies.has('authToken')) return NextResponse.next();

  // Build response
  const response = NextResponse.next();

  // Add cache control headers
  response.headers.set(
    'Cache-Control', 
    `public, max-age=${CACHE_MAX_AGE}, stale-while-revalidate=${STALE_WHILE_REVALIDATE}`
  );

  // Add Vary header to ensure correct cache keys
  response.headers.set('Vary', 'Accept-Encoding, x-user-locale');

  return response;
}

export const config = {
  matcher: [
    '/blog/:path*',
    '/products/:path*',
    '/documentation/:path*',
  ],
};

This middleware implements a stale-while-revalidate caching strategy for specific content paths, improving performance while ensuring content stays up to date.

Advanced Tips for Middleware Implementation

  1. Keep It Light: Middleware runs on every request, so keep the code lightweight and efficient.

  2. Error Handling: Add proper error handling to prevent middleware failures from affecting your application:

     export function middleware(request: NextRequest) {
       try {
         // Your middleware logic
         return NextResponse.next();
       } catch (error) {
         console.error('Middleware error:', error);
         return NextResponse.next();
       }
     }
    
  3. Chaining Middleware: While Next.js doesn't have built-in middleware chaining, you can organize your code into separate functions:

     function withLogging(request: NextRequest) {
       console.log(`Request: ${request.method} ${request.nextUrl.pathname}`);
       return NextResponse.next();
     }
    
     function withAuth(request: NextRequest, response: NextResponse) {
       // Auth logic
       return response;
     }
    
     export function middleware(request: NextRequest) {
       let response = withLogging(request);
       response = withAuth(request, response);
       return response;
     }
    
  1. Testing Middleware: Create unit tests for your middleware using tools like Jest:
import { createMocks } from 'node-mocks-http';
import { middleware } from './middleware';

describe('Authentication Middleware', () => {
  it('redirects unauthenticated users accessing protected routes', async () => {
    const { req } = createMocks({
      method: 'GET',
      url: '/dashboard',
    });

    const response = await middleware(req);

    expect(response.statusCode).toBe(302);
    expect(response.headers.get('Location')).toBe('/login');
  });
});

Conclusion

Next.js Middleware provides a powerful way to intercept and modify requests before they reach your application code. The five use cases we've explored—authentication, localization, A/B testing, request logging, and rate limiting—demonstrate the versatility and power of this feature.

By implementing middleware, you can create more robust applications with better security, improved user experiences, and enhanced performance. As Next.js continues to evolve, middleware will likely become an even more integral part of building sophisticated web applications.

Whether you're building a simple blog or an enterprise-grade application, Next.js Middleware offers powerful capabilities that can help you solve complex problems with elegant solutions.

Are you already using Next.js Middleware in your projects? What other use cases have you found particularly valuable? Share your experiences in the comments below!

0
Subscribe to my newsletter

Read articles from Abhiraj Ghosh directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Abhiraj Ghosh
Abhiraj Ghosh