Skip to main content
Context is a plain object that carries per-request data — the authenticated user, request headers, a database connection, a tracing span, and anything else your handlers need. It flows from the server handler through every middleware and into the final handler.

Context type

In oRPC, Context is defined as:
export type Context = Record<PropertyKey, any>
It is a plain record — no classes, no special base types.

Initial context

The initial context is the data you inject when the server receives a request. You provide it in the handler’s third argument:
import { createServer } from 'node:http'
import { RPCHandler } from '@orpc/server/node'

const handler = new RPCHandler(router)

const server = createServer(async (req, res) => {
  const result = await handler.handle(req, res, {
    context: { headers: req.headers } // initial context
  })

  if (!result.matched) {
    res.statusCode = 404
    res.end('No procedure matched')
  }
})

Declaring the context type

Tell oRPC what shape your initial context has by calling .$context<T>() on the os builder. This sets up the type constraint for all downstream middleware and handlers:
import type { IncomingHttpHeaders } from 'node:http'
import { os } from '@orpc/server'

// All procedures built from this builder expect { headers } in context
const authedOs = os.$context<{ headers: IncomingHttpHeaders }>()

export const createPlanet = authedOs
  .handler(async ({ context }) => {
    // context.headers is typed as IncomingHttpHeaders
    return { id: 1, name: 'Earth' }
  })
.$context<T>() resets any previously attached middleware. Call it before chaining .use() to set a fresh base context type.

Enriching context in middleware

Middleware can add new fields to the context by passing them through next():
import { os, ORPCError } from '@orpc/server'
import type { IncomingHttpHeaders } from 'node:http'

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

    if (user) {
      // Merge { user } into the context for downstream middleware and the handler
      return next({ context: { user } })
    }

    throw new ORPCError('UNAUTHORIZED')
  })
After this middleware runs, the current context type becomes { headers: IncomingHttpHeaders } & { user: User }. TypeScript tracks this precisely — you never need to cast.

Context merging

When multiple middleware in a chain each call next({ context: { ... } }), oRPC merges all the provided context objects. Later values override earlier ones for duplicate keys:
// Initial context: { headers }
// After authMiddleware: { headers, user }
// After tenantMiddleware: { headers, user, tenant }

export const myProcedure = os
  .$context<{ headers: IncomingHttpHeaders }>()
  .use(authMiddleware)   // adds { user }
  .use(tenantMiddleware) // adds { tenant }
  .handler(async ({ context }) => {
    // context: { headers, user, tenant } — all typed
    return context.tenant.name
  })

Sharing an os instance

A common pattern is to create a single pre-configured os instance with your context type and share it across your codebase:
router-base.ts
import type { IncomingHttpHeaders } from 'node:http'
import { os } from '@orpc/server'

// Shared base — all procedures expect { headers } in context
export const base = os.$context<{ headers: IncomingHttpHeaders }>()
planet.ts
import { base } from './router-base'
import * as z from 'zod'

export const listPlanet = base
  .input(z.object({ limit: z.number().optional() }))
  .handler(async ({ context }) => {
    // context.headers is available and typed
    return []
  })
Keep your initial context minimal — only include data that every procedure might need. Add richer data (user objects, database clients) in middleware where they are actually required.

Build docs developers (and LLMs) love