Skip to content

Instantly share code, notes, and snippets.

@z4nr34l
Last active October 11, 2024 12:20
Show Gist options
  • Save z4nr34l/7c2f98aa14329c20d3d3c728295b3511 to your computer and use it in GitHub Desktop.
Save z4nr34l/7c2f98aa14329c20d3d3c728295b3511 to your computer and use it in GitHub Desktop.
RFC: Next.js Dynamic Middleware

RFC: Next.js Dynamic Middleware

Summary

This RFC proposes a new approach to implementing middleware in Next.js, moving away from a single middleware.ts file to a more dynamic, route-specific implementation similar to the current handling of dynamic segments, pages, and layouts.

Motivation

The current middleware implementation in Next.js, while powerful, lacks the flexibility and modularity offered by other Next.js features like dynamic routing. This proposal aims to bring middleware in line with these other features, allowing for more granular control and easier management of middleware across a complex application.

Detailed Design

1. File-based Middleware

Instead of a single middleware.ts file at the root of the project, middleware will be implemented using middleware.ts files located within the directory structure, similar to how pages and layouts are currently handled.

Example structure:

src/
  app/
    dashboard/
      middleware.ts
    api/
      middleware.ts
    middleware.ts  # Root middleware

2. Default Export and Context Sharing

Each middleware.ts file will export a default function or promise that contains the middleware functionality. The middleware function will now receive three parameters:

import { NextRequest, NextFetchEvent } from 'next/server'

export default function middleware(
  request: NextRequest,
  event: NextFetchEvent,
  context: Map<string, any>
) {
  // Middleware logic here
  return NextResponse.next()
}

The new context parameter is a JavaScript Map that allows middleware functions to share data across the middleware chain. This enables more complex workflows and better separation of concerns.

Example of using the context:

// /src/app/middleware.ts
export default function rootMiddleware(req: NextRequest, evt: NextFetchEvent, context: Map<string, any>) {
  context.set('userRole', 'admin');
  return NextResponse.next();
}

// /src/app/dashboard/middleware.ts
export default function dashboardMiddleware(req: NextRequest, evt: NextFetchEvent, context: Map<string, any>) {
  const userRole = context.get('userRole');
  if (userRole !== 'admin') {
    return NextResponse.redirect('/login');
  }
  return NextResponse.next();
}

In this example, the root middleware sets a user role in the context, which is then used by the dashboard middleware to make an access control decision.

3. Path Matching

By default, a middleware file will apply to all routes under its directory, using a /:path* matching pattern. For example, /app/dashboard/middleware.ts would apply to /dashboard and all its subroutes.

4. Exact Path Matching

To allow for exact path matching, middleware files can export an exactPathMatching constant:

export const exactPathMatching = true

When exactPathMatching is set to true, the middleware will only be applied to the exact path where it's located. For example, if /app/dashboard/middleware.ts exports exactPathMatching = true, it will only apply to the /dashboard route and not to any subroutes.

If the exactPathMatching export is omitted or set to false, the default behavior (matching all subroutes) will be used.

5. Middleware Composition and Chaining

Middleware will be applied in order from the root to the most specific path. This allows for composing middleware functionality across different levels of the application.

When there are multiple middleware functions (either in the same file or in nested directories), they will be chained in order. The chain continues as long as each middleware function returns NextResponse.next(). If a middleware function returns anything else (such as a redirect or an error response), the chain will be broken, and that response will be sent to the client.

The context object is shared across all middleware functions in the chain, allowing them to pass data to each other. This context persists only for the duration of the current request and is not shared between different requests.

Crucially, if the last middleware in the chain returns NextResponse.next(), the middleware will return the response as modified by the entire chain. This allows each middleware in the chain to modify the request or response, with the final result incorporating all these modifications.

Example of chaining with context:

// /src/app/middleware.ts
export default function rootMiddleware(req: NextRequest, evt: NextFetchEvent, context: Map<string, any>) {
  console.log('Root middleware executed');
  context.set('executionStart', Date.now());
  const response = NextResponse.next();
  response.headers.set('X-Root-Header', 'root-value');
  return response;
}

// /src/app/dashboard/middleware.ts
export const exactPathMatching = true;

export default function dashboardMiddleware(req: NextRequest, evt: NextFetchEvent, context: Map<string, any>) {
  console.log('Dashboard middleware executed');
  const executionStart = context.get('executionStart');
  const executionTime = Date.now() - executionStart;
  context.set('executionTime', executionTime);
  const response = NextResponse.next();
  response.headers.set('X-Dashboard-Header', 'dashboard-value');
  response.headers.set('X-Execution-Time', `${executionTime}ms`);
  return response;
}

In this example, the root middleware sets an execution start time, which is then used by the dashboard middleware to calculate and set the total execution time.

Debugging and Developer Experience

To enhance the developer experience and make it easier to understand and debug middleware execution, we propose the following additions:

1. Vercel Toolbar Middleware Debugger

We will add a new section to the Vercel toolbar specifically for middleware debugging. This tool will:

  • Display a list of all middleware functions that were executed for the currently opened route.
  • Show the order of execution and the file path of each middleware.
  • Indicate whether each middleware function returned NextResponse.next() or broke the chain.
  • Provide a summary of any modifications made to the request or response by each middleware.

Example toolbar display:

Middleware Execution:
1. /src/app/middleware.ts (continued)
   - Added header: X-Root-Header
   - Context: Set 'executionStart'
2. /src/app/dashboard/middleware.ts (continued)
   - Added header: X-Dashboard-Header
   - Added header: X-Execution-Time
   - Context: Used 'executionStart', Set 'executionTime'
3. /src/app/dashboard/users/middleware.ts (chain end)
   - Redirected to: /login
   - Context: Used 'executionTime'

2. Enhanced Console Logging

We will add a new experimental flag in Next.js configuration to enable more granular middleware execution logging. When enabled, this will provide detailed logs in the Next.js console, showing:

  • Each middleware function that was considered for execution.
  • Whether the middleware was skipped (due to path matching rules) or executed.
  • The return value of each executed middleware (e.g., NextResponse.next(), redirect, etc.).
  • Any modifications made to the request or response.
  • The final composed response after all middleware executions.
  • Context operations performed by each middleware.

Example console output:

[Middleware] Request: GET /dashboard/users
[Middleware] Executing: /src/app/middleware.ts
             Result: NextResponse.next() with modifications
             - Added header: X-Root-Header
             - Context: Set 'executionStart'
[Middleware] Executing: /src/app/dashboard/middleware.ts
             Result: NextResponse.next() with modifications
             - Added header: X-Dashboard-Header
             - Added header: X-Execution-Time
             - Context: Used 'executionStart', Set 'executionTime'
[Middleware] Skipped: /src/app/dashboard/profile/middleware.ts (path mismatch)
[Middleware] Executing: /src/app/dashboard/users/middleware.ts
             Result: Redirect to /login
             - Context: Used 'executionTime'
[Middleware] Chain ended. Final result: Redirect to /login

These debugging tools will be invaluable for developers working with the new middleware system, allowing them to easily trace the execution path and understand how each middleware function affects the request and response.

Backwards Compatibility

To ensure backwards compatibility:

  1. The existing root middleware.ts file will continue to work as before.
  2. A migration guide will be provided to help developers transition to the new system.
  3. A codemod tool will be developed to assist in automatically refactoring existing middleware implementations.

Implementation

  1. Modify the Next.js build process to recognize and compile middleware.ts files throughout the project structure.
  2. Update the routing system to apply middleware based on file location and the exact export.
  3. Implement a system for composing and chaining multiple middleware functions, ensuring that modifications from all middleware in the chain are preserved in the final response.
  4. Create necessary TypeScript types and update existing types to support the new system.
  5. Develop the Middleware Debugger component for the Vercel toolbar, integrating it with the existing toolbar architecture.
  6. Implement the enhanced console logging system, including the new experimental flag in the Next.js configuration.
  7. Create APIs or hooks that allow middleware functions to report their execution details and modifications for debugging purposes.
  8. Implement the context parameter as a Map object that is created for each request and passed through the middleware chain.

Open Questions

  1. How should conflicts between middleware at different levels be resolved?
  2. Should there be a way to disable middleware inheritance for certain routes?
  3. How can we optimize performance when multiple middleware files are present?
  4. Should we provide a mechanism to explicitly break the middleware chain from within a middleware function?
  5. Should we provide built-in utilities or helper functions for common context operations, such as type-safe getters and setters?

Future Possibilities

  1. Middleware-specific DevTools for easier debugging and performance monitoring.
  2. A library of common middleware functions that can be easily imported and composed.
  3. Integration with other Next.js features like ISR and API Routes.
  4. Advanced middleware chaining controls, such as conditional execution or parallel processing of certain middleware functions.
  5. Interactive middleware debugging, allowing developers to modify and re-run middleware functions in real-time during development.
  6. Performance profiling for middleware, helping developers identify and optimize slow middleware functions.
  7. Integration with popular browser developer tools, providing middleware execution information directly in the browser's network tab.
  8. Typed contexts: Provide a way for developers to define and use typed contexts for improved type safety when sharing data between middleware functions.
@franky47
Copy link

franky47 commented Oct 9, 2024

Feedback

I would rename the exact export into something more explicit, like exactPathMatching. Does setting it also bypass the parent middleware?

Should we provide a mechanism to explicitly break the middleware chain from within a middleware function?

Isn't that done by returning a NextResponse at any point in the chain? Calling NextResponse.next() moves execution down the tree, but returning a response marks it as done, similar to what is done in the current flat middleware implementation.

Edit: sorry I had not yet been to the composition part, with execution capturing down, then bubbling up (a bit like DOM events). Maybe a similar API would work, by throwing some built-in functions to mark end of execution?

// /src/app/middleware.ts
export default function rootMiddleware(req: NextRequest, evt: NextFetchEvent, context: Map<string, any>) {
  const response = NextResponse.next();
  // Never reached
  return response;
}

// /src/app/dashboard/middleware.ts
export default function dashboardMiddleware(req: NextRequest, evt: NextFetchEvent, context: Map<string, any>) {
  throw NextResponse.redirect('/login')
}

type-safe getters and setters?

This is critical IMHO.

@z4nr34l
Copy link
Author

z4nr34l commented Oct 11, 2024

I've been looking to make it parallel to current layouts API - use layouts grouping to match chains etc. I was thinking that this solution can use layouts API to group/split/parallel middlewares just like layouts do - so bypassing parents could be done by a specific file name like template.tsx or directory name ^directory.

Renaming config var is a good idea.

I will try to make an research on other terms.

Thanks @franky47!

@franky47
Copy link

To be fair I don't think bypassing the parents is a good idea, it would make it all very confusing (and super hard to type the context).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment