Skip to main content
Middleware runs between the server receiving a request and your procedure handler executing. It can inspect or transform the input, enrich the context, short-circuit the call by throwing an error, or observe the output.

Writing middleware

Use os.middleware() to create a typed middleware:
import { os, ORPCError } from '@orpc/server'

const authMiddleware = os.middleware(async ({ context, next }) => {
  const user = await getUserFromToken(context.headers?.authorization)

  if (!user) {
    throw new ORPCError('UNAUTHORIZED')
  }

  // Call next() to continue the chain, passing additional context
  return next({ context: { user } })
})
The middleware function receives:
PropertyDescription
contextThe current context (from the server handler and previous middleware)
nextCall this to continue to the next middleware or handler
inputThe raw (pre-validation) input for this call
pathThe procedure’s path in the router
procedureReference to the current procedure
signalAbortSignal from the request
errorsType-safe error constructors

Attaching middleware

To a single procedure

export const createPlanet = os
  .$context<{ headers: IncomingHttpHeaders }>()
  .use(authMiddleware)
  .input(PlanetSchema.omit({ id: true }))
  .handler(async ({ input, context }) => {
    // context.user is available and typed here
    return { id: 1, name: input.name }
  })

To an entire router

export const planetRouter = os
  .use(authMiddleware)
  .router({
    list: listPlanet,
    find: findPlanet,
    create: createPlanet,
  })

Context transformation

Middleware can extend the context by passing additional properties to next(). The types are tracked precisely — downstream middleware and handlers see the merged context:
import type { IncomingHttpHeaders } from 'node:http'
import { os, ORPCError } from '@orpc/server'

// The middleware requires { headers } in context and adds { user }
const authMiddleware = os
  .$context<{ headers: IncomingHttpHeaders }>()
  .middleware(async ({ context, next }) => {
    const user = parseJWT(context.headers.authorization?.split(' ')[1])

    if (!user) {
      throw new ORPCError('UNAUTHORIZED')
    }

    return next({ context: { user } })
  })

export const createPlanet = os
  .$context<{ headers: IncomingHttpHeaders }>()
  .use(authMiddleware)
  .handler(async ({ input, context }) => {
    // TypeScript knows context.user is defined here
    console.log(context.user)
    return { id: 1, name: 'Earth' }
  })

Chaining multiple middleware

Call .use() multiple times to chain middleware. They execute in the order they are attached:
export const createPlanet = os
  .$context<{ headers: IncomingHttpHeaders }>()
  .use(authMiddleware)    // runs first
  .use(loggingMiddleware) // runs second
  .input(PlanetSchema.omit({ id: true }))
  .handler(async ({ input, context }) => {
    return { id: 1, name: input.name }
  })

Middleware concatenation

You can compose two middlewares into one using .concat():
const authAndLog = authMiddleware.concat(loggingMiddleware)

export const createPlanet = os
  .$context<{ headers: IncomingHttpHeaders }>()
  .use(authAndLog)
  .handler(async ({ input, context }) => {
    return { id: 1, name: 'Earth' }
  })

Input mapping

Middleware can operate on a mapped version of the input without changing the procedure’s public input schema. Pass a mapping function as the second argument to .use():
export const myProcedure = os
  .input(z.object({ userId: z.string() }))
  .use(
    async ({ context, next }, userId: string) => {
      const user = await db.user.findUnique({ where: { id: userId } })
      return next({ context: { user } })
    },
    (input) => input.userId, // map from { userId } to just the string
  )
  .handler(async ({ context }) => {
    return context.user
  })
Middleware created with os.middleware() is reusable across procedures and routers. Define shared middleware in a separate file and import it where needed.

Build docs developers (and LLMs) love