Middleware
Concepts

Middleware

Transform and control the notification pipeline

Middleware wraps the send pipeline. It runs before and after the render-and-send core, and it can transform the context, block a send entirely, or observe the result. If removing a piece of logic would change whether a notification goes out, it belongs in middleware. If it only observes or logs, it belongs in a hook.

sendwithRateLimitwithEventLoggerrenderSMTP?
const welcome = rpc
  .email()
  .input(schema)
  .subject(fn)
  .template(adapter)
  .use(withRateLimit({ max: 10, window: '1m', store, key: ({ args }) => args.to }))
  .use(withEventLogger({ sink }));

await mail.welcome.send({ to: 'ada@example.com', input: { name: 'Ada' } });

Middleware vs hooks

Prop

Type

What middleware can do

Every middleware receives the same parameters and decides how to proceed:

Prop

Type

There are four things a middleware can do with these parameters:

Pass through

Call next() to continue the pipeline unchanged. The simplest middleware does nothing but forward:

const passthrough: Middleware = async ({ next }) => {
  return next();
};

Mutate context

Pass an object to next() to shallow-merge fields into the context. Downstream middleware and the render function see the updated context:

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

Short-circuit

Don't call next(). Return a synthetic result instead. The render and transport never run:

const withDryRun = (): Middleware => {
  return async () => ({
    messageId: 'dry-run',
    accepted: [],
    rejected: [],
    envelope: { from: '', to: [] },
    timing: { renderMs: 0, sendMs: 0 },
  });
};

Fail

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

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

Composition order

Middleware wraps in layers. Plugin middleware sits outermost, procedure-level middleware sits innermost, and the render-and-send core is at the center:

plugin middleware
procedure middleware (.use)
render → transport → result

A request enters from the outside and moves inward. The response bubbles back out. Each layer can act on both directions — run logic before next() (on the way in) and after next() (on the way out).

Where you attach middleware

There are three levels, each with a different scope:

Root-level — runs on every procedure in the catalog:

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

Procedure-level — runs on a single procedure:

const passwordReset = rpc.email()
  .input(schema)
  .subject(fn)
  .template(adapter)
  .use(withRateLimit({ max: 3, window: '1h', key: ({ args }) => args.to }));

Plugins — bundle middleware (and hooks) into reusable units that apply across catalogs. See Plugins.

Built-in middleware

Writing custom middleware

A middleware is an async function that receives MiddlewareParams and returns the result. Wrap next() to run logic on both sides of the pipeline:

import type { Middleware } from '@betternotify/core';

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

The next() call is what separates "before" from "after". Everything above it runs on the way in; everything below runs on the way out.

On this page