All you need to know about Middleware in Nextjs

Abeer Abdul AhadAbeer Abdul Ahad
Sep 11, 2024·
9 min read

Let's get straight to the point. What is middleware in NextJS? In Next.js, middleware is a way to run certain code before a request is completed, just how the middleware concept of express.js works. It allows you to modify the request, response, or even control the response flow based on certain conditions.

One big advantage of middleware is that it works globally across the application, and since it runs at the edge (closer to the user), it's very fast. With Next.js 14 and the newer features, middleware is optimized even more for performance and server-side operations. It also integrates well with other modern Next.js features, like server-side rendering and API routes. We'll explore the concept of middleware in different situations.

How Middleware Handles Authentication Redirects

In Next.js, you can use middleware to check if a user is authenticated before they access certain pages or routes. This is useful for protected pages that require login, such as a dashboard or profile page. If the user is not authenticated, you can redirect them to the login page.

Let’s say we’re using a cookie named auth-token to check if the user is logged in. Here’s how middleware would work:

// middleware.ts
import { NextRequest, NextResponse } from 'next/server';

// Function to check authentication
export function middleware(req: NextRequest) {
  // Get the auth token from cookies
  const token = req.cookies.get('auth-token');

  // If no token exists, redirect to the login page
  if (!token) {
    return NextResponse.redirect(new URL('/login', req.url));
  }

  // If authenticated, allow access to the route
  return NextResponse.next();
}

// Apply this middleware to protected routes
export const config = {
  matcher: ['/dashboard', '/profile'], // Only run middleware on these routes
};

req.cookies.get('auth-token'): This checks for the auth-token cookie, which could be set after the user logs in.
NextResponse.redirect(): Redirects the user to the /login page if they are not authenticated.
NextResponse.next(): Allows the request to continue to the next stage (i.e., rendering the page) if the user is authenticated.
config.matcher: This is where you specify which routes should trigger the middleware. In this case, it's applied to /dashboard and /profile.

How Middleware Modifies Headers or Cookies

Middleware can be used to modify the headers or cookies of an incoming request or outgoing response. This is useful for various purposes, such as adding security headers, customizing responses, or managing cookies.

Modifying Headers: You can add, modify, or remove headers in the response object. This is often done for purposes like adding security headers or customizing the response.

// middleware.ts
import { NextRequest, NextResponse } from 'next/server';

export function middleware(req: NextRequest) {
  const response = NextResponse.next();

  // Modify response headers
  response.headers.set('X-Custom-Header', 'MyHeaderValue');
  response.headers.set('Cache-Control', 'no-store');

  return response;
}

export const config = {
  matcher: ['/api/*'], // Apply to API routes, for example
};

Why you would need to modify headers in the first place? Well, modifying headers in your middleware can be important for several reasons, such as preventing cross-site scripting (XSS) attacks, preventing your site from being embedded in iframes on other domains, mitigating clickjacking attacks, Caching Control, Cross-Origin Resource Sharing, Custom Headers for Tracking and Analytics, Compression and many more reasons. Now, you can do your research on internet to grasp more of these things.

Modifying Cookies: You can also set, modify, or delete cookies using middleware. This can be useful for tasks like managing session cookies or setting flags.

// middleware.ts
import { NextRequest, NextResponse } from 'next/server';

export function middleware(req: NextRequest) {
  const response = NextResponse.next();

  // Set a cookie in the response
  response.cookies.set('my-cookie', 'cookie-value', {
    httpOnly: true, // Cookie is accessible only via HTTP requests
    secure: process.env.NODE_ENV === 'production', // Only send over HTTPS in production
    maxAge: 60 * 60 * 24, // Cookie expiry time in seconds
  });

  // Modify an existing cookie
  response.cookies.set('existing-cookie', 'new-value');

  // Delete a cookie
  response.cookies.delete('cookie-to-delete');

  return response;
}

export const config = {
  matcher: ['/profile', '/settings'], // Apply to specific routes
};

How Middleware Restricts Access to Routes Based on User Roles

Let’s assume we have a role-based system where users can be either "admin" or "user." We want to restrict access to /admin routes to only users with the "admin" role.

// middleware.ts
import { NextRequest, NextResponse } from 'next/server';

// Middleware to restrict access based on user role
export function middleware(req: NextRequest) {
  // Get the user's role from cookies (assuming it's set during login)
  const userRole = req.cookies.get('user-role');

  // If the route is an admin route and the user is not an admin, deny access
  if (req.nextUrl.pathname.startsWith('/admin') && userRole !== 'admin') {
    return NextResponse.redirect(new URL('/unauthorized', req.url)); // Redirect to an unauthorized page
  }

  // If the user is allowed, continue to the next handler
  return NextResponse.next();
}

// Apply this middleware to specific routes
export const config = {
  matcher: ['/admin/:path*'], // Apply to all routes under /admin
};

Middleware’s Role in Server-Side Rendering (SSR) in Next.js

In server-side rendering (SSR), a page is rendered on the server for each request and then sent to the client as fully formed HTML. Middleware plays a role before or during this rendering process to inspect or manipulate the request and response.

If you ever coded with express.js, you would know that middleware functions are typically used to intercept requests, perform actions (like logging, parsing JSON, checking authentication), and then either modify the request/response or pass it on to the next middleware using next(). In Next.js with SSR, middleware is slightly different:

  1. It runs before rendering the page on the server.

  2. It can intercept requests to perform actions before the page is generated and served.

  3. Middleware can modify the request or response and decide if the user should even reach the page that will be server-rendered.

Middleware Flow in SSR

Explaining step by step process of middleware flow in SSR with bullet points will make it easy for you to understand it well. Here’s a basic flow of how middleware works in server-side rendering:

  1. Request Received: The user makes a request for an SSR page (e.g., /dashboard).

  2. Middleware Runs: Middleware intercepts the request before any server-side rendering happens.

    • It can inspect cookies, headers, or request body.

    • It can make decisions like allowing the request to proceed, redirecting, or even aborting the request (e.g., sending an error).

  3. Server-Side Rendering Starts: If the middleware allows the request to proceed (using NextResponse.next()), the page is server-rendered with any necessary data fetching or HTML generation.

  4. Middleware Modifies the Response (Optional): After SSR is done and before the response is sent to the client, middleware can still modify the response by adding or removing headers or cookies.

  5. Response Sent to Client: The fully rendered HTML is sent to the client after all middleware and SSR operations are complete.

Improvement in Middleware concept in Next.js 14

In Next.js 14, the optimization of middleware is a key improvement, especially when it comes to server-side rendering (SSR) and server-side operations.

Optimized Middleware in Next.js 14

Edge-First Architecture: Middleware is now more optimized to run at the edge, which means closer to the user geographically via Content Delivery Networks (CDNs). This reduces latency and improves response times, especially for global users.

  • Improved Latency: Instead of routing all requests to your central server, middleware can now handle requests at the edge, reducing the time it takes to process logic like authentication or redirects.

  • Reduced Load on Main Server: By moving some of the server-side logic to the edge, your main server has less load, which results in better overall performance for SSR and API routes.

Optimized for Static and Dynamic Routes: In Next.js 14, middleware is more tightly integrated with ISR (Incremental Static Regeneration) and SSR. Now, middleware can be used in combination with these rendering techniques more efficiently:

  • Middleware in ISR: Middleware can now handle logic (e.g., authentication, permissions) before pages are statically regenerated. This ensures that even in static builds, dynamic middleware logic can be executed.

  • Middleware and Dynamic SSR: For dynamic routes, middleware is executed more efficiently, allowing requests to be pre-processed (e.g., fetch user data, modify headers) before rendering the page on the server.

Improved Caching: Middleware can modify cache headers more intelligently. If you want certain routes or responses to be cached at the edge (via CDNs) while other parts remain dynamic, middleware in Next.js 14 helps manage that with more granular control. Middleware does Efficient Data Fetching which means it can pre-fetch data (for example, from a database or external API) and decide how the page should be cached. This avoids unnecessary fetches on the server, improving load times for SSR pages.

Streaming for SSR: Next.js 14 introduces streaming for SSR, allowing pages to be rendered and sent to the client in chunks. Middleware can now handle tasks like modifying headers or fetching data in parallel, speeding up the overall rendering process.

There are other improvements too. You can explore them as well.

Optimizing Middleware in Server-Side Rendering (SSR)

Imagine you want to handle user authentication at the edge and only allow logged-in users to view certain SSR pages. Middleware will now pre-process this logic closer to the user, improving the performance of the page load.

// middleware.ts
import { NextRequest, NextResponse } from 'next/server';

export function middleware(req: NextRequest) {
  const token = req.cookies.get('auth-token');

  // If no token exists, redirect to login page before SSR kicks in
  if (!token) {
    return NextResponse.redirect(new URL('/login', req.url));
  }

  // Allow access if the user is authenticated
  return NextResponse.next();
}

export const config = {
  matcher: ['/dashboard/:path*', '/profile/:path*'], // Apply middleware only to certain SSR pages
};

In this example, the middleware runs at the edge, allowing SSR to proceed only if the user is authenticated. This is faster because the logic is handled before the full server-side rendering process kicks in.

How to differ middleware tasks for different parts

In Next.js, middleware is typically configured in a single middleware.ts or middleware.js file, but you can modularize your middleware logic by creating separate files and then combining or using them within the main middleware configuration. However, in Next.js, middleware doesn’t have the concept of chaining next() like in Express, but it can handle modularization in a slightly different way.

Here’s how you can do it:

Example:

  1. auth-middleware.ts - Handles authentication.

  2. logging-middleware.ts - Logs requests.

  3. headers-middleware.ts - Modifies headers for specific routes.

auth-middleware.ts

import { NextResponse } from 'next/server';

export function authMiddleware(req) {
  const token = req.cookies.get('auth-token');
  if (!token) {
    return NextResponse.redirect(new URL('/login', req.url));
  }
  return NextResponse.next();
}
import { NextResponse } from 'next/server';

export function loggingMiddleware(req) {
  console.log(`Request made to: ${req.url}`);
  return NextResponse.next();
}
import { NextResponse } from 'next/server';

export function headersMiddleware(req) {
  const response = NextResponse.next();
  response.headers.set('x-custom-header', 'my-value');
  return response;
}

Next.js does not automatically support multiple middleware files, so you can combine them in your main middleware file based on route or logic.

import { authMiddleware } from './auth-middleware';
import { loggingMiddleware } from './logging-middleware';
import { headersMiddleware } from './headers-middleware';
import { NextRequest, NextResponse } from 'next/server';

export function middleware(req: NextRequest) {
  // Route-specific logic to apply different middlewares

  if (req.nextUrl.pathname.startsWith('/dashboard') || req.nextUrl.pathname.startsWith('/profile')) {
    // Apply authentication middleware for dashboard and profile routes
    return authMiddleware(req);
  }

  if (req.nextUrl.pathname.startsWith('/api')) {
    // Apply logging middleware for API routes
    return loggingMiddleware(req);
  }

  if (req.nextUrl.pathname.startsWith('/shop')) {
    // Apply header modification middleware for shop routes
    return headersMiddleware(req);
  }

  return NextResponse.next();  // Default response for other routes
}

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

Last Words

In Next.js, middleware plays a crucial role in the preprocessing and security of SSR pages. It allows you to intercept and handle requests before the SSR process starts. Middleware is designed to run closer to the user in edge locations, optimizing performance and reducing latency. It offers more fine-grained control compared to traditional server middleware like in Express.js, especially in SSR-specific scenarios like handling dynamic content, managing user sessions, or pre-fetching data.

14
Subscribe to my newsletter

Read articles from Abeer Abdul Ahad directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Abeer Abdul Ahad
Abeer Abdul Ahad

I am a Full stack developer. Currently focusing on Next.js and Backend.