Published Jun 17, 202414 min read
TypeScript for Next.js Middleware: Top 10 Best Practices

TypeScript for Next.js Middleware: Top 10 Best Practices

Using TypeScript for Next.js middleware development offers significant benefits, including type safety, better tooling, and improved maintainability. This article outlines the top 10 best practices to ensure robust, maintainable, and scalable code:

  1. Enable Strict Type Checking: Catch potential bugs during development by enabling strict type checking in your tsconfig.json file.

  2. Use Type Aliases and Interfaces: Define custom types using type aliases for primitive types and union types, and interfaces for object shapes and data structures.

  3. Type Middleware Functions: Explicitly type your middleware functions to ensure type safety, improve code clarity, and benefit from better tooling support.

  4. Type Request and Response Objects: Define types for NextRequest and NextResponse objects to access properties and methods with type safety and autocompletion.

  5. Handle Cookies and Headers: Manage cookies and headers securely and efficiently using the typed APIs provided by Next.js middleware.

  6. Implement Conditional Middleware: Apply different middleware logic based on conditions such as request paths, cookies, or headers.

  7. Combine Multiple Middleware Functions: Compose multiple middleware functions using a utility function, allowing you to execute them in a specific order.

  8. Handle Errors: Implement proper error handling by using try-catch blocks, creating custom error types, and creating a dedicated error handling middleware.

  9. Test Middleware: Test your middleware functions by mocking NextRequest and NextResponse objects, verifying middleware logic, testing middleware composition, and testing error handling scenarios.

  10. Keep Middleware Up-to-Date: Review upgrade guides and release notes, address deprecation warnings, thoroughly test your middleware, and monitor and log your application's behavior after upgrading to ensure compatibility and stability.

By following these best practices, you can leverage TypeScript's type safety and tooling support to build reliable and maintainable Next.js middleware.

1. Enable Strict Type Checking

Strict type checking in TypeScript helps catch potential bugs during development, improving code reliability and maintainability. To enable it, adjust the tsconfig.json file:

{
  "compilerOptions": {
    "strict": true
  }
}

With strict mode enabled, TypeScript performs additional checks, including:

Check Description
No implicit any types Prevents the use of the any type, which can lead to runtime errors.
Strict null and undefined checks Ensures that null and undefined values are handled correctly.
Strict object literal checks Checks that object literals match the expected shape.
Strict function types Enforces strict parameter and return type checking for functions.

These checks help catch common coding mistakes, such as accessing properties on null or undefined values, or passing incorrect types to functions.

You can also fine-tune strict type checking by enabling or disabling specific options. For example, noImplicitReturns ensures that all code paths in a function have an explicit return statement:

{
  "compilerOptions": {
    "strict": true,
    "noImplicitReturns": true
  }
}

While strict type checking may initially introduce more compilation errors, it leads to more robust and maintainable code. It's recommended to enable it from the start of your Next.js middleware development to catch potential issues early.

2. Type Aliases and Interfaces

TypeScript offers two ways to define custom types: type aliases and interfaces. Here's a simple explanation of each:

Type Aliases

Type aliases let you create a new name for an existing type. They're useful for giving types a more descriptive name.

type UserId = string | number; // Union type
type User = {
  id: UserId;
  name: string;
  email: string;
};

Interfaces

Interfaces define the shape of an object. They're flexible and can be extended or implemented by classes.

interface User {
  id: string | number;
  name: string;
  email: string;
  isAdmin?: boolean; // Optional property
}
Type Aliases Interfaces
Define primitive types or union types Define object shapes or classes
Provide semantic naming for complex types Can be extended or implemented
Alias utility types (e.g., React.FC<Props>) Describe data structures

When to Use Each

For Next.js middleware, you'll often use interfaces to define the shape of request and response objects, as well as custom middleware functions. Type aliases can be used for utility types or naming complex types.

In general, prefer interfaces when:

  • Defining object shapes or classes

  • Extending or implementing interfaces

  • Describing data structures

Use type aliases for:

  • Defining primitive types or union types

  • Providing semantic naming for complex types

  • Aliasing utility types

3. Typed Middleware Functions

In Next.js, middleware functions handle incoming requests and perform operations before passing control to the route handler or the next middleware. TypeScript allows you to define types for these middleware functions, ensuring type safety and better code maintainability.

Here's an example of a typed middleware function:

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

const authMiddleware: NextMiddleware = (request: NextRequest) => {
  const authToken = request.cookies.get('auth_token');

  if (!authToken) {
    return NextResponse.redirect('/login');
  }

  return NextResponse.next();
};

export default authMiddleware;

In this example, authMiddleware checks for the presence of an auth_token cookie. If the cookie is missing, it redirects the user to the login page. Otherwise, it passes control to the next middleware or the route handler.

Key points:

  • Import the required types (NextRequest, NextResponse, and NextMiddleware) from next/server.

  • Define the middleware function with the correct type signature: (request: NextRequest) => NextResponse | void.

  • Use the NextRequest object to access request data, such as cookies, headers, or query parameters.

  • Return a NextResponse object to modify the response or pass control to the next middleware/route handler.

By explicitly typing the middleware function, you benefit from:

Benefit Description
Type Safety TypeScript ensures the middleware function adheres to expected types, catching potential errors during development.
Code Clarity The types make the function's input and output explicit, improving code readability and maintainability.
Tooling Support IDEs and editors provide better autocompletion, type checking, and code navigation for typed middleware functions.

4. Typed Request and Response Objects

In Next.js middleware, you can use TypeScript to define types for the NextRequest and NextResponse objects. This ensures type safety and improves code maintainability. Here's an example:

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

const loggerMiddleware: NextMiddleware = (request: NextRequest) => {
  console.log(`Received ${request.method} request to ${request.url}`);

  const response = NextResponse.next();
  response.headers.set('X-Request-Timestamp', new Date().toISOString());

  return response;
};

export default loggerMiddleware;

In this example, the loggerMiddleware function logs the request method and URL, and sets a custom header with the request timestamp.

By defining types for NextRequest and NextResponse, you can:

  • Access request properties (e.g., request.method, request.url) with type safety and autocompletion.

  • Manipulate response headers and cookies using typed methods (e.g., response.headers.set()).

  • Ensure the middleware function adheres to the expected type signature: (request: NextRequest) => NextResponse | void.

Benefit Description
Type Checking TypeScript ensures you're using the correct properties and methods for NextRequest and NextResponse, catching potential errors during development.
Code Clarity The types make the input and output of the middleware function explicit, improving code readability and maintainability.
Tooling Support IDEs and editors provide better autocompletion, type checking, and code navigation for typed request and response objects.

5. Handling Cookies and Headers

Cookies and headers play a vital role in Next.js middleware. Cookies help manage user sessions, while headers allow you to customize responses and enforce security measures. TypeScript provides robust type safety and tooling support for working with cookies and headers.

Secure HTTPOnly Cookies

HTTPOnly cookies enhance security by preventing client-side scripts from accessing them, mitigating risks like cross-site scripting (XSS) attacks.

import { NextResponse } from 'next/server';

export function middleware(request: NextRequest) {
  const response = NextResponse.next();
  response.cookies.set({
    name: 'sessionId',
    value: 'abc123',
    httpOnly: true, // Mark the cookie as HTTPOnly
  });

  return response;
}

In the example above, the httpOnly option is set to true, marking the sessionId cookie as HTTPOnly and enhancing security against client-side attacks.

Managing Cookies and Headers

Next.js middleware provides a straightforward API for managing cookies and headers. You can read, set, and delete cookies, as well as modify request and response headers.

import { NextResponse } from 'next/server';

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

  // Read a cookie
  const userPreference = request.cookies.get('userPreference')?.value;

  // Set a cookie
  response.cookies.set('lastVisit', new Date().toISOString());

  // Modify a response header
  response.headers.set('X-Custom-Header', 'Hello, Middleware!');

  return response;
}

In this example, the middleware reads the userPreference cookie, sets a new lastVisit cookie with the current date, and adds a custom X-Custom-Header response header.

sbb-itb-1aa3684

6. Conditional Middleware

Conditional middleware allows you to apply different logic based on specific conditions or criteria. This is useful when you need to handle different scenarios or routes within your application.

Execution Based on Paths

You can apply middleware logic based on the requested path using the matcher configuration option.

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

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

  if (pathname.startsWith('/admin')) {
    // Logic for admin routes
    return NextResponse.rewrite(new URL('/login', request.url));
  }

  if (pathname.startsWith('/api')) {
    // Logic for API routes
    const response = NextResponse.next();
    response.headers.set('X-API-Key', 'abcd1234');
    return response;
  }

  return NextResponse.next();
}

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

In this example, the middleware redirects to the login page for admin routes and sets a custom header for API routes.

Execution Based on Cookies or Headers

You can also apply middleware logic based on the presence or absence of specific cookies or headers in the request.

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

export function middleware(request: NextRequest) {
  const isAuthenticated = request.cookies.get('auth_token');
  const isFeatureFlagEnabled = request.headers.get('X-Feature-Flag') === 'enabled';

  if (!isAuthenticated) {
    return NextResponse.rewrite(new URL('/login', request.url));
  }

  if (isFeatureFlagEnabled) {
    // Logic for the enabled feature
    const response = NextResponse.next();
    response.headers.set('X-Feature-Enabled', 'true');
    return response;
  }

  return NextResponse.next();
}

In this example, the middleware checks for an auth_token cookie to determine if the user is authenticated. If not, it redirects to the login page. It also checks for a specific header value (X-Feature-Flag) to enable or disable a feature flag.

7. Combining Multiple Middleware Functions

In Next.js, you may need to apply several middleware functions to handle different aspects of a request. Middleware composition allows you to combine and execute multiple middleware functions in a specific order.

To compose multiple middlewares, you can create a utility function that takes an array of middleware functions and returns a single middleware function:

const composeMiddlewares = (middlewares: Array<(req: NextRequest) => NextResponse | Promise<NextResponse>>) => {
  return (req: NextRequest) => {
    const initialResponse = Promise.resolve(NextResponse.next());
    return middlewares.reduce((prevPromise, middleware) => {
      return prevPromise.then((res) => {
        if (res?.status >= 300 && res?.status < 400) {
          // If the previous middleware redirected, skip the current one
          return res;
        } else {
          return middleware(req);
        }
      });
    }, initialResponse);
  };
};

This composeMiddlewares function takes an array of middleware functions and returns a new middleware function that executes each middleware in the provided order. If a middleware function redirects the request (with a status code between 300 and 400), the remaining middlewares are skipped.

You can then use this composed middleware in your middleware.ts file:

import { authenticateMiddleware, cacheMiddleware, logMiddleware } from './middlewares';

const middlewares = [authenticateMiddleware, cacheMiddleware, logMiddleware];
export default composeMiddlewares(middlewares);

This approach allows you to separate your middleware logic into individual functions and combine them as needed. It also provides flexibility to add, remove, or reorder middleware functions without modifying the individual middleware implementations.

Applying Middleware Based on Conditions

You can also conditionally apply middlewares based on specific criteria, such as the request path or headers. This can be achieved by wrapping the middleware composition logic within conditional statements or by creating separate composed middlewares for different routes.

import { composeMiddlewares } from './utils';
import { authenticateMiddleware, cacheMiddleware, logMiddleware, apiMiddleware } from './middlewares';

const apiMiddlewares = [authenticateMiddleware, apiMiddleware, logMiddleware];
const webMiddlewares = [authenticateMiddleware, cacheMiddleware, logMiddleware];

export default function middleware(request: NextRequest) {
  if (request.nextUrl.pathname.startsWith('/api')) {
    return composeMiddlewares(apiMiddlewares)(request);
  } else {
    return composeMiddlewares(webMiddlewares)(request);
  }
}

In this example, different sets of middlewares are composed and executed based on whether the request is for an API route or a web route. This approach allows you to tailor the middleware pipeline for different parts of your application.

8. Error Handling

Proper error handling is crucial for building reliable and maintainable Next.js middleware with TypeScript. Here are some best practices:

Use Try-Catch Blocks

Wrap your middleware logic in a try-catch block to catch and handle any errors that may occur during request processing. This prevents your application from crashing and allows you to provide a custom error response.

import { NextResponse } from 'next/server';

export async function middleware(request: NextRequest) {
  try {
    // Your middleware logic here
    return NextResponse.next();
  } catch (error) {
    // Handle the error
    console.error(error);
    return NextResponse.json({ error: 'An error occurred' }, { status: 500 });
  }
}

Create Custom Error Types

Define custom error types to represent different types of errors that can occur in your application. This makes it easier to handle and respond to specific errors appropriately.

class AuthenticationError extends Error {
  constructor(message: string) {
    super(message);
    this.name = 'AuthenticationError';
  }
}

class NotFoundError extends Error {
  constructor(message: string) {
    super(message);
    this.name = 'NotFoundError';
  }
}

Use Error Handling Middleware

Create a dedicated error handling middleware that catches and handles errors thrown by other middlewares or route handlers. This middleware can log errors, format error responses, and perform any necessary cleanup or fallback actions.

import { NextResponse } from 'next/server';
import { AuthenticationError, NotFoundError } from './errors';

export function errorHandlerMiddleware(error: Error, request: NextRequest) {
  if (error instanceof AuthenticationError) {
    return NextResponse.json({ error: error.message }, { status: 401 });
  } else if (error instanceof NotFoundError) {
    return NextResponse.json({ error: error.message }, { status: 404 });
  } else {
    // Log the error for debugging purposes
    console.error(error);
    return NextResponse.json({ error: 'An unexpected error occurred' }, { status: 500 });
  }
}

Compose Error Handling Middleware

Combine your error handling middleware with other middlewares using the middleware composition technique discussed in the previous section. This ensures that errors are caught and handled appropriately, regardless of where they occur in the middleware chain.

import { composeMiddlewares } from './utils';
import { authMiddleware, cacheMiddleware, errorHandlerMiddleware } from './middlewares';

const middlewares = [authMiddleware, cacheMiddleware, errorHandlerMiddleware];
export default composeMiddlewares(middlewares);

9. Testing Middleware

Testing middleware is vital to ensure your Next.js application works correctly. Here are some best practices:

Mock NextRequest and NextResponse

To test your middleware functions, you'll need to mock the NextRequest and NextResponse objects. Jest provides utilities for creating mock objects and spying on their methods.

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

const mockRequest = new NextRequest('https://example.com');
const mockResponse = NextResponse.next();

Test Middleware Logic

Write tests to verify that your middleware functions handle different scenarios correctly. Use Jest's describe and it blocks to organize and label your tests.

import { middleware } from './middleware';

describe('Middleware', () => {
  it('should return a response for anonymous users', async () => {
    const request = new NextRequest('https://example.com');
    const response = await middleware(request);
    expect(response.status).toBe(200);
    expect(await response.text()).toContain('Anon!');
  });

  it('should return a response for authenticated users', async () => {
    const request = new NextRequest('https://example.com');
    request.headers.set('Authorization', 'Bearer token');
    const response = await middleware(request);
    expect(response.status).toBe(200);
    expect(await response.text()).toContain('Hello, world!');
  });
});

Test Middleware Composition

If you're using middleware composition, test the composed middleware function to ensure that the individual middlewares are executed in the correct order.

import { composeMiddlewares } from './utils';
import { authMiddleware, cacheMiddleware } from './middlewares';

const composedMiddleware = composeMiddlewares([authMiddleware, cacheMiddleware]);

describe('Composed Middleware', () => {
  it('should execute middlewares in the correct order', async () => {
    const request = new NextRequest('https://example.com');
    const response = await composedMiddleware(request);
    // Assert expected behavior based on the composed middleware
  });
});

Test Error Handling

Test your error handling middleware by simulating different types of errors and verifying that the middleware responds with the appropriate error messages and status codes.

import { errorHandlerMiddleware } from './middlewares';

describe('Error Handler Middleware', () => {
  it('should handle authentication errors', async () => {
    const error = new AuthenticationError('Invalid token');
    const request = new NextRequest('https://example.com');
    const response = errorHandlerMiddleware(error, request);
    expect(response.status).toBe(401);
    expect(await response.json()).toEqual({ error: 'Invalid token' });
  });

  // Test other error scenarios
});

10. Keeping Your Middleware Up-to-Date

As Next.js evolves, middleware functionality may change across versions. To keep your application compatible and benefit from the latest improvements, follow these steps:

Review Upgrade Guides and Release Notes

Before upgrading to a new Next.js version, carefully review the official upgrade guide and release notes. These resources outline any changes or new features related to middleware, providing step-by-step instructions for a smooth transition.

// Example upgrade steps from Next.js 12 to 13
import { NextResponse } from 'next/server'

export function middleware(request) {
  const response = NextResponse.next()
  // Set custom headers for all responses
  response.headers.set('X-Custom-Header', 'value')
  return response
}

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

Address Deprecation Warnings

Next.js typically provides warnings for features scheduled for removal in future versions. Pay close attention to these warnings and address them promptly to avoid compatibility issues.

Test and Integrate

Implement comprehensive tests for your middleware functions to catch any regressions or breaking changes introduced by upgrades. Integrate these tests into your continuous integration (CI) pipeline to ensure middleware functionality remains stable across deployments.

Monitor and Log

Closely monitor your application's behavior after upgrading, especially in production environments. Implement robust logging mechanisms to track any issues or unexpected behavior related to middleware execution. This proactive approach can help you identify and resolve problems quickly, minimizing downtime.

Benefit Description
Compatibility Keeping your middleware up-to-date ensures compatibility with the latest Next.js version, allowing you to take advantage of new features and improvements.
Stability Addressing deprecation warnings and thoroughly testing your middleware helps maintain stability and prevent regressions.
Maintainability Regularly upgrading and monitoring your middleware code makes it easier to maintain and troubleshoot issues.
Security Staying up-to-date with the latest Next.js version helps ensure your application benefits from the latest security patches and bug fixes.

Conclusion

Using TypeScript for Next.js middleware development offers many benefits that help build solid, easy-to-maintain, and scalable web applications. TypeScript's static type checking and tooling support catch potential errors during development, improving code quality and reducing runtime issues.

Following best practices like strict type checking, typed middleware functions, and typed request and response objects ensures your middleware code is type-safe and less prone to bugs. Techniques like middleware composition, conditional middleware, and error handling make your middleware implementation more flexible and reliable.

Best Practice Benefit
Strict Type Checking Catches potential bugs during development
Typed Middleware Functions Ensures type safety and improves code maintainability
Typed Request and Response Objects Provides type checking and better tooling support
Middleware Composition Allows combining multiple middleware functions
Conditional Middleware Applies different logic based on conditions or routes
Error Handling Catches and handles errors appropriately

Additionally, testing your middleware and keeping it up-to-date with the latest Next.js releases ensure long-term maintainability and compatibility.

Related posts