Skip to main content
Ship has two layers of middleware, and they’re the same primitive — an oRPC middleware that either refines context or throws before the handler runs:
  • Global middlewares — applied to every endpoint automatically. Cross-cutting concerns: logging, timing, request context.
  • Per-endpoint gates — opt-in with .use(...): isAuthorized, isAdmin, canAccess, canEdit. Covered in Routing → Middlewares.

Global middlewares

Every endpoint builds on the @/endpoint builder, which applies the whole @/middlewares/global list — in order — before any per-endpoint .use(...):
middlewares/global/index.ts
import logger from '@/middlewares/global/logger';

// Applied to every endpoint, in order, before any per-endpoint `.use(...)`.
// Must be context-preserving (cross-cutting side effects: logging, timing, request-id).
const globalMiddlewares = [logger];

export default globalMiddlewares;
endpoint.ts reduces that list onto the oRPC base, so adding an always-on middleware is a one-line change — register it in @/middlewares/global and every endpoint picks it up. Nothing in endpoint.ts changes:
endpoint.ts
import { os } from '@orpc/server';

import globalMiddlewares from '@/middlewares/global';
import type { ORPCContext } from '@/types';

const base = os.$context<ORPCContext>();

export default globalMiddlewares.reduce(
  (builder, middleware) => builder.use(middleware),
  base,
);
Global middlewares must be context-preserving — cross-cutting side effects only. Anything that narrows the context (auth, ownership) is a gate you stack per-endpoint, so its type narrowing reaches the handler.

logger

The default global middleware binds a request-scoped logger. It derives the endpoint name from the oRPC path and runs the rest of the request inside an AsyncLocalStorage scope, so every log line downstream is tagged automatically:
middlewares/global/logger.ts
export default base.middleware(async ({ context, path, next }) => {
  const endpoint = path.at(-1) ?? 'unknown';
  const child = appLogger.child({ endpoint });
  return loggerStorage.run(child, () => next({ context }));
});

Adding a global middleware

Write a context-preserving oRPC middleware, then register it:
middlewares/global/timing.ts
import { os } from '@orpc/server';
import type { ORPCContext } from '@/types';

const base = os.$context<ORPCContext>();

export default base.middleware(async ({ context, path, next }) => {
  const start = performance.now();
  const result = await next({ context });
  appLogger.info(`${path.at(-1)} took ${Math.round(performance.now() - start)}ms`);
  return result;
});
middlewares/global/index.ts
import logger from '@/middlewares/global/logger';
import timing from '@/middlewares/global/timing';

const globalMiddlewares = [logger, timing];

export default globalMiddlewares;

Where the context comes from

Global middlewares don’t build the context — Hono does, before oRPC takes over. server.ts constructs an ORPCContext per request (cookies, headers, the resolved better-auth session) and hands it to the oRPC handler. By the time logger runs, context.user is already populated for signed-in requests. See Routing overview.

Validation and errors

You don’t reach for a validate middleware — validation is the endpoint. .input(zodSchema) validates the request and .output(zodSchema) shapes the response; a parse failure surfaces as a BAD_REQUEST with field-level errors. Throw ORPCError (or a ClientError / AppError from @/types) for everything else; server.ts normalises them into the response. See Routing → Middlewares for the gate-level errors (UNAUTHORIZED, FORBIDDEN, NOT_FOUND).

See also