Type-Safe RPC with ORPC
Relevant source files
The following files were used as context for generating this wiki page:
This project utilizes ORPC (Open-standard RPC) to facilitate type-safe communication between the Hono backend (apps/api) and the React frontend (apps/web). The core principle is a "contract-first" approach, where a shared contract package defines the API endpoints, data structures, and validation schemas. This contract acts as a single source of truth, enabling fully-typed client and server implementations, which eliminates a wide range of runtime errors and improves developer experience.
The ORPC implementation is central to the monorepo's architecture. It leverages Zod for schema validation, integrates with TanStack Query on the client for state management, and automatically generates OpenAPI documentation for the API. This ensures that the frontend and backend are always in sync regarding the API's shape and behavior.
Sources: README.md, packages/contract/README.md
Architecture Overview
The ORPC system is structured around three main parts within the monorepo: the shared contract, the API server implementation, and the client-side integration.
This diagram shows the data flow and dependency relationship. The contract package is the central piece, consumed by both the api server to implement the endpoints and the web client to make type-safe calls.
Sources: README.md:27-33, apps/api/package.json:11, apps/web/package.json:15
Contract Definition (packages/contract)
The @contract-based-monorepo/contract package is the single source of truth for the entire API. It uses ORPC's contract definition utilities and Zod for schema validation.
Route and Schema Definition
API routes are defined using oc.route from @orpc/contract. Each route definition includes:
- HTTP method and path
- A summary and tags for documentation
- Input validation schema (using Zod)
- Output validation schema (using Zod)
- Type-safe error definitions
Data shapes are defined using Zod schemas in separate files, which are then imported into the route definitions. For example, the planet entity has schemas for creation, updates, and the final object shape.
Sources: packages/contract/src/contract/planet.ts, packages/contract/src/schemas/planet.ts
Example: planet Schemas
The schemas for the planet entity are defined using Zod, including a special oz.file() type from @orpc/zod for file uploads.
| Schema Name | Description |
|---|---|
NewPlanetSchema | Defines the shape for creating a new planet. |
UpdatePlanetSchema | Defines the shape for updating an existing planet, requiring an id. |
PlanetSchema | Defines the full planet object, including its id and creator. |
Sources: packages/contract/src/schemas/planet.ts:7-27
Example: listPlanets Route Contract
The following code defines the contract for the endpoint that lists planets. It specifies the path, method, input parameters (limit, cursor) with defaults, and the expected output (an array of PlanetSchema).
// packages/contract/src/contract/planet.ts:7-18
export const listPlanets = oc
.route({
method: "GET",
path: "/planets",
summary: "List all planets",
tags: ["Planets"],
})
.input(
z.object({
limit: z.number().int().min(1).max(100).default(10),
cursor: z.number().int().min(0).default(0),
}),
)
.output(z.array(PlanetSchema));
Sources: packages/contract/src/contract/planet.ts:7-18
Server-Side Implementation (apps/api)
The apps/api service consumes the contract to build a Hono-based server with type-safe route handlers.
Router and Handlers
The main router in apps/api/src/router/index.ts composes all the defined route handlers into a single ORPC router object.
// apps/api/src/router/index.ts:6-19
export const router = pub.router({
auth: {
signup,
signin,
me,
},
planet: {
list: listPlanets,
create: createPlanet,
find: findPlanet,
update: updatePlanet,
},
sse,
});
Sources: apps/api/src/router/index.ts:6-19
Each route handler implements the logic defined in the contract. The handler function receives typed input and context objects.
// apps/api/src/router/planet.ts:5-8
export const listPlanets = pub
.use(retry({ times: 3 }))
.planet.list.handler(async ({ input, context }) => {
return context.db.planets.list(input.limit, input.cursor);
});
Sources: apps/api/src/router/planet.ts:5-8
Context and Middleware
ORPC on the server uses a context object that is passed to all handlers. This context can be extended using middleware. In this project, ORPCContext is defined to potentially hold user and session data.
Two main procedure builders are exported from apps/api/src/orpc.ts:
pub: A public procedure builder that attaches the database provider middleware (dbProviderMiddleware).authed: A protected procedure builder that extendspuband adds an authentication check, throwing aUNAUTHORIZEDerror if no user is present in the context.
// apps/api/src/orpc.ts:13-23
export const authed = pub.use(({ context, next }) => {
if (!context.user) {
throw new ORPCError("UNAUTHORIZED");
}
return next({
context: {
user: context.user,
},
});
});
Sources: apps/api/src/orpc.ts:6-23, apps/api/src/middlewares/db.ts:14-28
Request Handling Flow
Incoming HTTP requests are processed by Hono middleware, which delegates to ORPC handlers. The handleRPCRequests function matches the incoming request path against the ORPC router and executes the corresponding handler.
The following diagram illustrates a typical request-response flow for an RPC call.
This sequence shows how a request to /rpc/planet.list is routed through the Hono server to the specific ORPC handler, which then interacts with the database and returns a typed result.
Sources: apps/api/src/handlers/orpc.ts:28-41, apps/api/src/router/planet.ts:5-8
Client-Side Integration (apps/web)
The frontend application uses a generated ORPC client to communicate with the API, providing end-to-end type safety.
Client and React Query Setup
The client is configured in apps/api/src/lib/orpc.ts (though intended for web usage). It uses createORPCClient with an RPCLink that specifies the server URL (/rpc) and how to attach authorization headers.
For seamless integration with React, the project uses @orpc/react-query. The createORPCReactQueryUtils function wraps the orpcClient to create a set of type-safe hooks for use with TanStack Query.
// apps/api/src/lib/orpc.ts:6-16
const rpcLink = new RPCLink({
url: new URL("/rpc", "http://localhost:3000"),
headers: () => ({
Authorization: "Bearer default-token",
}),
});
export const orpcClient: ContractRouterClient<typeof contract> =
createORPCClient(rpcLink);
export const orpc = createORPCReactQueryUtils(orpcClient);
This setup allows developers in the web app to call API procedures with full type-checking and autocompletion. For example, to fetch a list of planets, a developer could use orpc.planet.list.useQuery().
Sources: apps/api/src/lib/orpc.ts, README.md:16
OpenAPI and Documentation
A significant advantage of the contract-first approach is the ability to auto-generate API documentation. The project uses @orpc/openapi to generate an OpenAPI v3 specification from the shared contract.
The handleOpenAPISpec function in apps/api/src/handlers/openapi.ts uses OpenAPIGenerator along with ZodToJsonSchemaConverter to convert the Zod schemas in the contract into JSON Schema. The generated spec is then served at /api/spec.json.
This spec powers an interactive Scalar API documentation page, available at /api/docs.
// apps/api/src/handlers/openapi.ts:7-9
const openAPIGenerator = new OpenAPIGenerator({
schemaConverters: [new ZodToJsonSchemaConverter()],
});
Sources: apps/api/src/handlers/openapi.ts:7-29, README.md:73-74
Summary
The integration of ORPC provides a robust and type-safe foundation for communication between the project's frontend and backend. By defining a clear contract in the packages/contract module, the system guarantees that the client, server, and documentation are always synchronized. This architecture reduces bugs, simplifies development by providing autocompletion and type-checking across the stack, and streamlines API documentation through automatic generation.