NextRush
Guides

Custom Middleware

Build request logging and authentication middleware for NextRush using the factory pattern.

What You Will Build

You will create two production-quality middleware functions:

  1. Request logger — logs method, path, status, and duration for every request
  2. Authentication guard — validates bearer tokens and attaches user data to ctx.state

Along the way, you will learn the middleware type signature, the factory pattern, error handling, composition, and testing.

Prerequisites

  • A working NextRush application (Quick Start)
  • Basic understanding of async/await

The Middleware Contract

Every middleware function receives two arguments:

import type { Context, Middleware, Next } from '@nextrush/types';

// Formal signature
type Middleware = (ctx: Context, next: Next) => void | Promise<void>;

next calls the downstream middleware and returns when that downstream work finishes. NextRush also wires next onto the context, so both styles work:

// Style 1 — next parameter (traditional, Koa-compatible)
const mw: Middleware = async (ctx, next) => {
  console.log('before');
  await next();
  console.log('after');
};

// Style 2 — ctx.next() (modern, one-argument)
const mw2: Middleware = async (ctx) => {
  console.log('before');
  await ctx.next();
  console.log('after');
};

Which style to use?

Both styles are equivalent. Use ctx.next() in application code for brevity. Use the next parameter when building reusable middleware that may be called outside the compose pipeline (conditional wrappers, testing).

Middleware executes in an onion model — code before next() runs on the way in, code after runs on the way out:

Request → [MW1 before] → [MW2 before] → Handler

Response ← [MW1 after] ← [MW2 after] ←────┘

Step 1 — Request Logger

A logging middleware that records request duration and assigns a trace ID.

import type { Middleware } from '@nextrush/types';

interface LoggerOptions {
  level?: 'debug' | 'info' | 'warn' | 'error';
}

function requestLogger(options: LoggerOptions = {}): Middleware {
  const { level = 'info' } = options;

  return async (ctx, next) => {
    const start = performance.now();
    const id = crypto.randomUUID();

    ctx.state.requestId = id;
    ctx.set('X-Request-Id', id);

    await next();

    const ms = (performance.now() - start).toFixed(2);
    console.log(`[${level.toUpperCase()}] ${ctx.method} ${ctx.path} ${ctx.status} ${ms}ms [${id}]`);
  };
}

Register it:

app.use(requestLogger({ level: 'debug' }));

The factory pattern — a function that returns a Middleware — is how all configurable middleware is built. The options are validated once at startup; the returned function runs per-request.

Step 2 — Authentication Middleware

This middleware verifies a bearer token and short-circuits with an error when authentication fails.

import type { Middleware } from '@nextrush/types';
import { UnauthorizedError } from '@nextrush/errors';

interface AuthOptions {
  /** Paths that skip authentication */
  exclude?: string[];
  /** Header containing the token */
  header?: string;
  /** Your token verification function */
  verify: (token: string) => Promise<{ id: string; role: string }>;
}

function auth(options: AuthOptions): Middleware {
  const { exclude = [], header = 'authorization', verify } = options;

  return async (ctx, next) => {
    if (exclude.some((p) => ctx.path.startsWith(p))) {
      return next();
    }

    const raw = ctx.get(header);
    if (!raw) {
      throw new UnauthorizedError('Missing authentication token');
    }

    const token = raw.replace(/^Bearer\s+/i, '');

    try {
      ctx.state.user = await verify(token);
    } catch {
      throw new UnauthorizedError('Invalid or expired token');
    }

    await next();
  };
}

Register it after your logger so requests are logged even when auth fails:

app.use(requestLogger());
app.use(
  auth({
    exclude: ['/health', '/login'],
    verify: myTokenVerifier, // your implementation
  })
);

When authentication fails, UnauthorizedError produces a 401 response and skips all downstream middleware.

Step 3 — Error Handling

Wrap next() in a try/catch to observe or transform errors thrown by downstream middleware.

import type { Middleware } from '@nextrush/types';
import { NotFoundError, ConflictError } from '@nextrush/errors';

function transformErrors(): Middleware {
  return async (ctx, next) => {
    try {
      await next();
    } catch (err: unknown) {
      if (err instanceof Error && 'code' in err) {
        const code = (err as { code: string }).code;
        if (code === 'P2025') throw new NotFoundError('Resource not found');
        if (code === 'P2002') throw new ConflictError('Resource already exists');
      }
      throw err;
    }
  };
}

Never swallow errors silently. Always re-throw or respond explicitly. A catch block with no throw hides failures from the error handler.

Step 4 — Composition and Conditional Application

Composing middleware into a single unit

import { compose } from '@nextrush/core';

const apiStack = compose([
  requestLogger(),
  auth({ exclude: ['/health'], verify: myTokenVerifier }),
  transformErrors(),
]);

app.use(apiStack);

Applying middleware conditionally

When calling middleware directly (outside compose), pass next as the second argument:

import type { Context, Middleware } from '@nextrush/types';

function when(predicate: (ctx: Context) => boolean, middleware: Middleware): Middleware {
  return async (ctx, next) => {
    if (predicate(ctx)) {
      return middleware(ctx, next);
    }
    await next();
  };
}

// Skip logging in production
app.use(when(() => process.env.NODE_ENV !== 'production', requestLogger()));

Step 5 — Middleware Order

Middleware runs in registration order. Security middleware must be registered before route handlers.

// ✅ Correct order
app.use(requestLogger());   // 1. Log every request
app.use(auth({ ... }));     // 2. Authenticate
app.route('/api', router);  // 3. Route to handlers

// ❌ Wrong order — routes execute before auth
app.route('/api', router);
app.use(auth({ ... }));

Verification — Testing Your Middleware

import { describe, it, expect, vi } from 'vitest';
import type { Context, Next } from '@nextrush/types';

function createMockContext(overrides: Partial<Context> = {}): Context {
  return {
    method: 'GET',
    path: '/test',
    status: 200,
    state: {},
    get: vi.fn(),
    set: vi.fn(),
    json: vi.fn(),
    next: vi.fn().mockResolvedValue(undefined),
    ...overrides,
  } as unknown as Context;
}

describe('requestLogger', () => {
  it('assigns a request ID and calls next', async () => {
    const mw = requestLogger();
    const ctx = createMockContext();
    const next = vi.fn().mockResolvedValue(undefined);

    await mw(ctx, next);

    expect(ctx.state.requestId).toBeDefined();
    expect(ctx.set).toHaveBeenCalledWith('X-Request-Id', expect.any(String));
    expect(next).toHaveBeenCalledOnce();
  });
});

describe('auth', () => {
  it('throws UnauthorizedError when no token is present', async () => {
    const mw = auth({
      verify: vi.fn(),
    });
    const ctx = createMockContext({
      get: vi.fn(() => undefined),
    });
    const next = vi.fn();

    await expect(mw(ctx, next)).rejects.toThrow('Missing authentication token');
    expect(next).not.toHaveBeenCalled();
  });
});

Next Steps

On this page