Custom Middleware
Middlewares

Custom Middleware

Write your own middleware for the send pipeline

A middleware is an async function that wraps the send pipeline. It receives a set of parameters and decides whether to continue, modify the context, short-circuit, or fail. Everything above next() runs on the way in; everything below runs on the way out.

Use createMiddleware to define one — it gives you full autocomplete on the parameters without needing to import and annotate the Middleware type:

import { createMiddleware } from '@betternotify/core/middlewares';

const withTiming = () =>
  createMiddleware(async ({ route, next }) => {
    const start = performance.now();
    const result = await next();
    console.log(`${route}: ${(performance.now() - start).toFixed(1)}ms`);
    return result;
  });

What the pipeline gives you

Every middleware receives a params object from the pipeline. Destructure only what you need — next is the only one most middleware uses:

createMiddleware(async ({ next }) => next())
createMiddleware(async ({ route, input, next }) => { ... })

Prop

Type

Patterns

Pass through

The simplest middleware — forward everything unchanged:

const noop = () => createMiddleware(async ({ next }) => next());

Enrich context

Pass an object to next() to add fields to the context. Downstream middleware and the render function see the updated context:

const withTenant = () =>
  createMiddleware(async ({ next }) => {
    const tenantId = await resolveTenant();
    return next({ tenantId });
  });

Wrap and observe

Call next() in the middle to run logic on both sides of the pipeline:

const withMetrics = () =>
  createMiddleware(async ({ route, next }) => {
    metrics.increment(`${route}.attempts`);
    const result = await next();
    metrics.increment(`${route}.success`);
    return result;
  });

Short-circuit

Don't call next() to skip the rest of the pipeline. Return a synthetic result:

const withCachedResult = (cache: Map<string, unknown>) =>
  createMiddleware(async ({ route, args, next }) => {
    const key = `${route}:${JSON.stringify(args.to)}`;
    const cached = cache.get(key);
    if (cached) return cached;
    const result = await next();
    cache.set(key, result);
    return result;
  });

Fail

Throw to abort the pipeline. The error routes to onError hooks:

import { NotifyRpcError } from '@betternotify/core';
import { createMiddleware } from '@betternotify/core/middlewares';

const withBlocklist = (blocked: Set<string>) =>
  createMiddleware(async ({ args, next }) => {
    if (blocked.has(String(args.to))) {
      throw new NotifyRpcError({ message: 'Recipient blocked', code: 'BLOCKED' });
    }
    return next();
  });

Typed input

Pass the input type as a generic to createMiddleware to narrow input to a specific schema. This is useful when the middleware depends on the shape of the procedure's input:

type OrderInput = { orderId: string; email: string };

const withOrderDedup = () => {
  const seen = new Set<string>();
  return createMiddleware<OrderInput>(async ({ input, next }) => {
    if (seen.has(input.orderId)) {
      return { messageId: 'dedup', timing: { renderMs: 0, sendMs: 0 } };
    }
    seen.add(input.orderId);
    return next();
  });
};

Attaching middleware

Middleware can be attached at three levels:

const welcome = rpc.email()
  .input(schema)
  .subject(fn)
  .template(adapter)
  .use(withTiming());

const rpc = createNotify({ channels: { email } })
  .use(withTenant());

const observability = createPlugin({
  name: 'observability',
  middleware: [withTiming(), withMetrics()],
  hooks: { onError: ({ error }) => errorTracker.capture(error) },
});

See Middleware for composition order and how middleware differs from hooks.

On this page