oRPC contract-first
Build oRPC APIs where the contract is the shared source of truth. In a monorepo, put the contract in a dedicated package that both API implementations and clients can import. Keep runtime business logic, database code, request handlers, environment access, and framework adapters out of that package.
Primary docs to prefer when details change:
https://orpc.dev/docs/contract-first/define-contracthttps://orpc.dev/docs/contract-first/implement-contracthttps://orpc.dev/docs/contract-first/router-to-contracthttps://orpc.dev/docs/openapi/openapi-to-contracthttps://orpc.dev/docs/best-practices/monorepo-setup
Core model
Use this mental split:
- Contract package: defines schemas, procedure contracts, contract routers, exported inferred input/output types, and optionally generated OpenAPI-derived contracts.
- Server package/app: imports the contract, calls
implement(contract), attaches.handler(...)implementations, builds the rootos.router(...), and exposes the API through framework adapters. - Client app/package: imports the contract for type-safe clients/links, query helpers, generated client utilities, or request method typing. It must not import server procedures or implementation modules.
The contract package is an interface artifact. Treat it like a public API package inside the workspace.
Recommended monorepo layout
Prefer one of these structures unless the repo already has a stronger convention.
Contract-first:
apps/
api/ # imports @repo/contracts and implements it
web/ # imports @repo/contracts and configures @orpc/client
packages/
contracts/
src/
schemas/
contract.ts
index.ts
Hybrid split, useful when implementation is reused by multiple runtimes:
apps/
api/ # imports @repo/core-service and mounts framework adapter
web/ # imports @repo/contracts and configures @orpc/client
packages/
contracts/ # @orpc/contract only, schemas, route metadata
core-service/ # imports contract, uses @orpc/server, exports router/procedures
Use TypeScript project references for cross-package type safety. Packages that are consumed by apps should generally set "composite": true; consuming apps should reference the relevant package or rely on the workspace's established build graph. Avoid path aliases inside shared server/contract components when workspace package imports are available.
Contract package rules
When creating or editing the shared contract package:
- Import
ocfrom@orpc/contract; do not importosor server adapters. - Define validation schemas near the contracts that use them, or in domain-oriented
schemas/files if reused. - Export a root
contractobject that mirrors the API domain hierarchy. - Export inferred types with
InferContractRouterInputsandInferContractRouterOutputswhen consumers need stable named types. - Keep the package free of database clients, auth/session lookup, framework request objects, environment variables, and service classes.
- Prefer domain names over transport names:
planet.find,invoice.create,account.members.list. - Use route metadata only for public API description concerns such as OpenAPI routes, method/path, tags, and summaries. Do not smuggle implementation choices into metadata.
Example shape:
import { oc } from '@orpc/contract'
import type {
InferContractRouterInputs,
InferContractRouterOutputs,
} from '@orpc/contract'
import { z } from 'zod'
export const PlanetSchema = z.object({
id: z.number().int().min(1),
name: z.string(),
description: z.string().optional(),
})
export const contract = {
planet: {
list: oc
.input(z.object({
limit: z.number().int().min(1).max(100).optional(),
cursor: z.number().int().min(0).default(0),
}))
.output(z.array(PlanetSchema)),
find: oc
.input(PlanetSchema.pick({ id: true }))
.output(PlanetSchema),
create: oc
.input(PlanetSchema.omit({ id: true }))
.output(PlanetSchema),
},
}
export type ContractInputs = InferContractRouterInputs<typeof contract>
export type ContractOutputs = InferContractRouterOutputs<typeof contract>
Server implementation rules
When implementing the contract:
- Import
implementfrom@orpc/serverand the sharedcontractfrom the contract package. - Create the implementer once near the server's oRPC composition root:
const os = implement(contract). - Implement each procedure by attaching
.handler(...)to the corresponding contract path. - Build the root router with
os.router(...); this is the step that type-checks and enforces the full contract at runtime. - Keep framework adapters and transport concerns outside the contract package.
- Use context/middleware from the server layer for auth, database access, tracing, and per-request dependencies.
Example shape:
import { implement } from '@orpc/server'
import { contract } from '@repo/contracts'
const os = implement(contract)
const listPlanet = os.planet.list.handler(async ({ input, context }) => {
return context.planets.list(input)
})
const findPlanet = os.planet.find.handler(async ({ input, context }) => {
return context.planets.find(input.id)
})
const createPlanet = os.planet.create.handler(async ({ input, context }) => {
return context.planets.create(input)
})
export const router = os.router({
planet: {
list: listPlanet,
find: findPlanet,
create: createPlanet,
},
})
If a handler cannot satisfy the contract output, fix the implementation or the contract deliberately. Do not cast around the mismatch; the mismatch is the contract doing its job.
Client consumption rules
Clients should import the shared contract package, not server implementation modules.
Use the contract for:
ContractRouterClient<typeof contract>typing.RPCLinkclients for oRPC RPC endpoints.OpenAPILinkclients for OpenAPI-style endpoints generated/exposed from the contract.- Generated query/mutation helpers.
- Named input/output types exported from the contract package.
- Mock clients in tests or Storybook-style environments.
Minimal RPC client:
import { createORPCClient, onError } from '@orpc/client'
import { RPCLink } from '@orpc/client/fetch'
import type { ContractRouterClient } from '@orpc/contract'
import { contract } from '@repo/contracts'
const link = new RPCLink({
url: '/rpc',
headers: () => ({
authorization: `Bearer ${getAccessToken()}`,
}),
interceptors: [
onError((error) => {
reportClientError(error)
}),
],
})
export const orpc: ContractRouterClient<typeof contract> = createORPCClient(link)
Calling procedures should look like plain function calls:
const planets = await orpc.planet.list({ limit: 20 })
const planet = await orpc.planet.find({ id: 1 })
Use client context when headers, cache behavior, or tenancy differ per call:
import { createORPCClient } from '@orpc/client'
import { RPCLink } from '@orpc/client/fetch'
import type { ContractRouterClient } from '@orpc/contract'
import { contract } from '@repo/contracts'
interface ClientContext {
accessToken?: string
cache?: RequestCache
}
const link = new RPCLink<ClientContext>({
url: '/rpc',
headers: ({ context }) => ({
authorization: context?.accessToken ? `Bearer ${context.accessToken}` : '',
}),
fetch: (request, init, { context }) => {
return globalThis.fetch(request, {
...init,
cache: context?.cache,
})
},
})
export const orpc: ContractRouterClient<typeof contract, ClientContext> =
createORPCClient(link)
await orpc.planet.list(
{ limit: 20 },
{ context: { accessToken, cache: 'force-cache' } },
)
When contract routes specify HTTP methods, prefer deriving the RPC request method from the shared contract instead of repeating method logic in the client:
import { inferRPCMethodFromContractRouter } from '@orpc/contract'
import { RPCLink } from '@orpc/client/fetch'
import { contract } from '@repo/contracts'
const link = new RPCLink({
url: '/rpc',
method: inferRPCMethodFromContractRouter(contract),
})
For extra boundary checks in contract-first clients, add request and/or response validation plugins at the link layer. This works best with real contracts because minified contracts remove schemas.
import { RPCLink } from '@orpc/client/fetch'
import {
RequestValidationPlugin,
ResponseValidationPlugin,
} from '@orpc/contract/plugins'
import { contract } from '@repo/contracts'
const link = new RPCLink({
url: '/rpc',
plugins: [
new RequestValidationPlugin(contract),
new ResponseValidationPlugin(contract),
],
})
For OpenAPI clients, pass the contract into OpenAPILink and type the client from the contract. Account for JSON serialization limitations when the link requires jsonified client types.
import { createORPCClient } from '@orpc/client'
import type { ContractRouterClient } from '@orpc/contract'
import type { JsonifiedClient } from '@orpc/openapi-client'
import { OpenAPILink } from '@orpc/openapi-client/fetch'
import { contract } from '@repo/contracts'
const link = new OpenAPILink(contract, {
url: '/api',
})
export const api: JsonifiedClient<ContractRouterClient<typeof contract>> =
createORPCClient(link)
Avoid:
- Importing
router, procedures, database types, or server context into web/mobile clients. - Putting client-only helpers into the contract package if they pull in browser framework dependencies.
- Duplicating schemas in the client app when they already exist in the contract package.
Migrating from service/router-first
If an existing codebase already has an oRPC server router:
- Check whether the router contains lazy routers. If it does, resolve it first with
unlazyRouter(router)before deriving a contract-compatible router. - If the client needs contract data from a server-derived router, use
minifyContractRouter(router)and export JSON rather than importing the server router directly. - Treat minified JSON as a bridge, not the ideal monorepo target. For new work, move stable public API shape into the shared contract package.
- When importing minified JSON client-side, preserve type safety by casting it to
typeof routeras documented, because schema types are not serializable in JSON.
Use router-to-contract migration when extraction is incremental. The end state should still separate public contract from internal business logic.
Generating from OpenAPI
If the source of truth is an existing OpenAPI specification:
- Use
@hey-api/openapi-tswith theorpcplugin to generate an oRPC-compatible contract. - Prefer generating into a shared contract package, or into a generated subfolder that the shared package re-exports.
- Use
validator.input: 'zod'when the project uses Zod for runtime input validation. - Keep generated files isolated from handwritten domain conveniences so regeneration does not overwrite local code.
- Remember the Hey API
orpcplugin is documented as beta; pin versions and review generated diffs.
Example config:
import { defineConfig } from '@hey-api/openapi-ts'
export default defineConfig({
input: './openapi.json',
output: 'packages/contracts/src/generated',
plugins: [
{
name: 'orpc',
validator: {
input: 'zod',
},
},
],
})
Package boundaries and dependencies
Use dependency direction as the sanity check:
apps/web ------------> packages/contracts <------------- apps/api
^
|
packages/core-service
Allowed dependencies:
contractsmay depend on@orpc/contract, schema libraries such aszod, and small type-only/domain packages.core-serviceorapps/apimay depend oncontracts,@orpc/server, database packages, auth packages, and framework adapters.apps/webmay depend oncontracts,@orpc/client, UI/query libraries, and browser framework packages.
Disallowed dependencies:
contractsimporting@orpc/server, API framework adapters, DB clients, auth/session services, or app-specific config.apps/webimportingcore-service, server routers, server context, or API app files.- Server implementation packages importing UI/client packages.
Implementation checklist
Before finishing an oRPC contract-first change, verify:
- The shared contract package exports the root
contractand any intended schemas/types. - Server code uses
implement(contract)and builds a rootos.router(...). - The client imports the contract package or generated/minified contract artifact, never server internals.
- TypeScript project references or workspace package builds let apps resolve contract types without falling back to
any. - Contract package dependency list does not include server-only or client-only runtime dependencies.
- Generated OpenAPI-derived contracts are isolated and reproducible.
- Tests or type checks cover at least one client import path and one server implementation path when the change touches package boundaries.
Common failure modes
- Putting handlers in the contract package. Move them to
core-serviceor the API app. - Duplicating schemas per app. Promote shared request/response schemas to the contract package.
- Importing server routers in the client. Import the shared contract, or use
minifyContractRouterJSON for router-derived migration cases. - Skipping
os.router(...). Handlers alone do not assemble the fully enforced router. - Casting away output errors. Fix the handler output or contract; do not paper over mismatches.
- Path-alias-only sharing. Prefer real workspace package imports so project references and package boundaries remain honest.