A middleware composition library for Next.js applications that allows you to organize and chain middleware functions based on URL patterns.
npm install @rescale/nemo
pnpm add @rescale/nemo
bun add @rescale/nemo
- Path-based middleware routing
- Global middleware support (before/after)
- Context sharing between middleware via shared storage
- Support for Next.js native middleware patterns
- Request/Response header and cookie forwarding
- Middleware nesting and composition
- Built-in logging system accessible in all middleware functions
This example shows all possible options of NEMO usage and middlewares compositions, including nested routes:
import { createNEMO } from '@rescale/nemo';
export default createNEMO({
// Simple route with middleware chain
'/api': [
// First middleware in the chain
async (request, { storage }) => {
storage.set('timestamp', Date.now());
// Continues to the next middleware
},
// Second middleware accesses shared storage
async (request, { storage }) => {
const timestamp = storage.get('timestamp');
console.log(`Request started at: ${timestamp}`);
}
],
// Nested routes using object notation
'/dashboard': {
// This middleware runs on /dashboard
middleware: async (request) => {
console.log('Dashboard root');
},
// Nested route with parameter
'/:teamId': {
// This middleware runs on /dashboard/:teamId
middleware: async (request, { params }) => {
console.log(`Team dashboard: ${params.teamId}`);
},
// Further nesting with additional parameter
'/users/:userId': async (request, { params }) => {
console.log(`Team user: ${params.teamId}, User: ${params.userId}`);
}
},
// Another nested route under /dashboard
'/settings': async (request) => {
console.log('Dashboard settings');
}
},
// Pattern matching multiple routes
'/(auth|login)': async (request) => {
console.log('Auth page');
}
});
Each middleware in a chain is executed in sequence until one returns a response or all are completed. Nested routes allow you to organize your middleware hierarchically, matching more specific paths while maintaining a clean structure.
When a request matches a nested route, NEMO executes middleware in this order:
- Global
before
middleware (if defined) - Root path middleware (
/
) for all non-root requests - Parent middleware (using the
middleware
property) - Child middleware
- Global
after
middleware (if defined)
If any middleware returns a response (like a redirect), the chain stops and that response is returned immediately.
type NextMiddleware = (
request: NextRequest,
event: NemoEvent
) => NextMiddlewareResult | Promise<NextMiddlewareResult>;
The standard middleware function signature used in NEMO, compatible with Next.js native middleware.
type MiddlewareConfig = Record<string, MiddlewareConfigValue>;
A configuration object that maps route patterns to middleware functions or arrays of middleware functions.
type GlobalMiddlewareConfig = Partial<
Record<"before" | "after", NextMiddleware | NextMiddleware[]>
>;
Configuration for global middleware that runs before or after route-specific middleware.
function createNEMO(
middlewares: MiddlewareConfig,
globalMiddleware?: GlobalMiddlewareConfig,
config?: NemoConfig
): NextMiddleware
Creates a composed middleware function with enhanced features:
- Executes middleware in order (global before → path-matched middleware → global after)
- Provides shared storage context between middleware functions
- Handles errors with custom error handlers
- Supports custom storage adapters
interface NemoConfig {
debug?: boolean;
silent?: boolean;
errorHandler?: ErrorHandler;
enableTiming?: boolean;
storage?: StorageAdapter | (() => StorageAdapter);
}
To make it easier to understand, you can check the below examples:
Matches /dashboard
route exactly.
/dashboard
Path parameters allow you to capture parts of the URL path. The general pattern is :paramName
where paramName
is the name of the parameter that will be available in the middleware function's event.params
object.
Named parameters are defined by prefixing a colon to the parameter name (:paramName).
/dashboard/:team
This matches /dashboard/team1
and provides team
param with value team1
.
You can also place parameters in the middle of a path pattern:
/team/:teamId/dashboard
This matches /team/123/dashboard
and provides teamId
param with value 123
.
You can include multiple parameters in a single pattern:
/users/:userId/posts/:postId
This matches /users/123/posts/456
and provides parameters userId: "123", postId: "456"
.
Parameters can have a custom regexp pattern in parentheses, which overrides the default match:
/icon-:size(\\d+).png
This matches /icon-123.png
but not /icon-abc.png
and provides size
param with value 123
.
Parameters can be suffixed with a question mark (?
) to make them optional:
/users/:userId?
This matches both /users
and /users/123
.
Parameters can be wrapped in curly braces {}
to create custom prefixes or suffixes:
/product{-:version}?
This matches both /product
and /product-v1
and provides version
param with value v1
when present.
Parameters can be suffixed with an asterisk (*
) to match zero or more segments:
/files/:path*
This matches /files
, /files/documents
, /files/documents/work
, etc.
Parameters can be suffixed with a plus sign (+
) to match one or more segments:
/files/:path+
This matches /files/documents
, /files/documents/work
, etc., but not /files
.
You can match multiple pattern alternatives by using parentheses and the pipe character:
/(auth|login)
This matches both /auth
and /login
.
The matcher fully supports Unicode characters in both patterns and paths:
/café/:item
This matches /café/croissant
and provides item
param with value croissant
.
You can constrain route parameters to match only specific values or exclude certain values:
// Match only if :lang is either 'en' or 'cn'
const nemo = new NEMO({
"/:lang(en|cn)/settings": [
// This middleware only runs for /en/settings or /cn/settings
(req) => {
const { lang } = req.params;
// lang will be either 'en' or 'cn'
return NextResponse.next();
},
],
});
// Exclude specific values from matching
const nemo = new NEMO({
"/:path(!api)/:subpath": [
// This middleware runs for any /:path/:subpath EXCEPT when path is 'api'
// e.g., /docs/intro will match, but /api/users will not
(req) => {
const { path, subpath } = req.params;
return NextResponse.next();
},
],
});
import { createNEMO } from '@rescale/nemo';
export const middleware = createNEMO({
// Simple route
'/api': async (request) => {
// Handle API routes
},
// With parameter
'/users/:userId': async (request, event) => {
// Access parameter
console.log(`User ID: ${event.params.userId}`);
},
// Optional pattern with custom prefix
'/product{-:version}?': async (request, event) => {
// event.params.version will be undefined for '/product'
// or the version value for '/product-v1'
console.log(`Version: ${event.params.version || 'latest'}`);
},
// Pattern with custom matching
'/files/:filename(.*\\.pdf)': async (request, event) => {
// Only matches PDF files
console.log(`Processing PDF: ${event.params.filename}`);
}
});
import { createNEMO } from '@rescale/nemo';
export default createNEMO({
'/api{/*path}': apiMiddleware,
},
{
before: [loggerMiddleware, authMiddleware],
after: cleanupMiddleware,
});
The Storage API allows you to share data between middleware executions:
import { createNEMO } from '@rescale/nemo';
export default createNEMO({
'/': [
async (req, { storage }) => {
// Set values
storage.set('counter', 1);
storage.set('items', ['a', 'b']);
storage.set('user', { id: 1, name: 'John' });
// Check if key exists
if (storage.has('counter')) {
// Get values (with type safety)
const count = storage.get<number>('counter');
const items = storage.get<string[]>('items');
const user = storage.get<{id: number, name: string}>('user');
// Delete a key
storage.delete('counter');
}
}
]
});
Access URL parameters through the event's params property:
import { createNEMO } from '@rescale/nemo';
export default createNEMO({
'/users/:userId': async (request, event) => {
const { userId } = event.params;
console.log(`Processing request for user: ${userId}`);
}
});
NEMO provides built-in logging capabilities through the event object that maintains consistent formatting and respects the debug configuration:
import { createNEMO } from '@rescale/nemo';
export default createNEMO({
'/api': async (request, event) => {
// Debug logs (only shown when debug: true in config)
event.log('Processing API request', request.nextUrl.pathname);
try {
// Your API logic
const result = await processRequest(request);
event.log('Request processed successfully', result);
return NextResponse.json(result);
} catch (error) {
// Error logs (always shown)
event.error('Failed to process request', error);
// Warning logs (always shown)
event.warn('This endpoint will be deprecated soon');
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
}
}
}, undefined, { debug: true });
All logs maintain the "[NEMO]" prefix for consistency with internal framework logs.
- Middleware functions are executed in order until a Response is returned
- The storage is shared between all middleware functions in the chain
- Headers and cookies are automatically forwarded between middleware functions
- Supports Next.js native middleware pattern
I'm working with Next.js project for a few years now, after Vercel moved multiple /**/_middleware.ts
files to a single /middleware.ts
file, there was a unfilled gap - but just for now.
After a 2023 retro I had found that there is no good solution for that problem, so I took matters into my own hands. I wanted to share that motivation with everyone here, as I think that we all need to remember how it all started.
Hope it will save you some time and would make your project DX better!