Contract First Development | Orpc
Every API has a contract, even when nobody wrote it down.
In many projects, that contract is spread across backend route handlers, frontend interfaces, mobile DTOs, Postman collections, stale docs, and one Slack message nobody wants to lose.
The frontend sends a payload the backend no longer accepts. The mobile app expects a field that was renamed. The docs show one response, the server returns another, and the bug only appears when real users reach production. That is the problem contract-first APIs are meant to reduce.
oRPC lets you define the API contract once and reuse it across the server, clients, docs, and query layer. The contract describes routes, inputs, outputs, validation rules, and expected errors. The backend implements it. Clients consume it. Docs come from it.
This repo demonstrates that pattern with a small product catalog demonstration with the following stack:
- Bun
- Turborepo
- Hono
- oRPC
- Zod
- static TypeScript data
- OpenAPI
- Scalar API docs
- Next.js
- Expo
- TanStack Query
- Sentry
You can find the full example on GitHub
One note before starting, The data layer is static TypeScript data on purpose. That keeps the article focused on the boundary that matters: the API contract. You can replace the data layer later with PostgreSQL, Drizzle, Prisma, SQLite, or an external service without changing how web and mobile call the API.
What we are building
The project is a monorepo with one shared API contract and three runtimes.
orpcexample/
├── apps/
│ ├── server/
│ ├── web/
│ └── mobile/
├── packages/
│ ├── db/
│ ├── orpc/
│ ├── ui/
│ ├── eslint-config/
│ └── typescript-config/
├── turbo.json
└── package.jsonEach part has a narrow job
packages/db owns product data and query helpers.
packages/orpc owns the contract, Zod schemas, typed client, and TanStack Query helpers.
apps/server implements the contract with Hono and exposes RPC, REST, OpenAPI, and Scalar docs.
packages/web consumes the same contract from a Next.js app.
apps/mobile consumes the same contract from an Expo app.
The important part: web and mobile do not define their own API types. They import the same contract-aware client instead.
Why contract-first APIs help
The benefit is not just autocomplete, Autocomplete is useful, but the bigger win is that oRPC makes drift harder.
When the API contract is shared:
- route names are not guessed by clients
- request payloads are validated at the boundary
- response shapes are derived from the same schema
- expected errors become part of the API design
- REST/OpenAPI docs come from the implemented router
- TanStack Query options come from the typed client
- web and mobile stop maintaining duplicate DTOs
That changes the daily workflow. Instead of asking “what does this endpoint return now?”, the team changes the contract and lets TypeScript show every place that must be updated.
Domain schemas in the data layer
Before defining the oRPC contract, we define the domain shapes in packages/db.
That may feel odd at first, but it keeps ownership clear.
packages/db owns categories and products, so it also owns their Zod schemas. packages/orpc should not redefine those shapes. It should consume them.
The data layer starts with Zod schemas. This is the only place where the product and category shapes are defined.
The contract package
The heart of the repo is packages/orpc . This package does not render UI. It does not own database access. It describes the boundary between server and consumers.
// packages/db/src/categories/types.ts
import { z } from "zod";
export const CategorySlugSchema = z.enum([
"keyboard",
"mouse",
"monitor",
"audio",
"accessory",
]);and types
// packages/db/src/product/types.ts
import { z } from "zod";
import { CategorySlugSchema } from "../categories";
export const ProductSchema = z.object({
id: z.string().startsWith("prod_"),
slug: z.string().min(1),
name: z.string().min(1),
category: CategorySlugSchema,
description: z.string().min(1),
priceCents: z.number().int().nonnegative(),
currency: z.literal("USD"),
inStock: z.boolean(),
tags: z.array(z.string().min(1)),
pictures: z.array(
z.object({
url: z.string().url(),
alt: z.string().min(1),
label: z.string().min(1),
}),
),
});The Contract Package
Now packages/orpc can focus on the API boundary.
It imports the domain schemas from @repo/db, adds API-specific input schemas, and describes the routes.
// packages/orpc/src/contract.ts
import { oc } from "@orpc/contract";
import { CategorySchema, CategorySlugSchema, ProductSchema } from "@repo/db";
import { z } from "zod";
const ListProductsInputSchema = z.object({
category: CategorySlugSchema.optional(),
inStock: z.boolean().optional(),
search: z.string().trim().min(1).optional(),
limit: z.number().int().min(1).max(50).optional(),
});
export const contract = {
categories: {
list: oc
.route({
method: "GET",
path: "/categories",
summary: "List product categories.",
})
.output(z.array(CategorySchema)),
},
products: {
list: oc
.route({
method: "GET",
path: "/products",
summary: "List products.",
})
.input(ListProductsInputSchema)
.output(z.array(ProductSchema)),
byId: oc
.route({
method: "GET",
path: "/products/{id}",
summary: "Get product by ID.",
})
.errors(ProductNotFoundError)
.input(ProductIdInputSchema)
.output(ProductSchema),
bySlug: oc
.route({
method: "GET",
path: "/products/by-slug/{slug}",
summary: "Get product by slug.",
})
.errors(ProductNotFoundError)
.input(ProductSlugInputSchema)
.output(ProductSchema),
reserve: oc
.route({
method: "POST",
path: "/products/{productId}/reservations",
summary: "Reserve product stock.",
successStatus: 201,
})
.errors({
...ProductNotFoundError,
...ProductUnavailableError,
})
.input(ReserveProductInputSchema)
.output(ReservationSchema),
},
};This avoids defining the product shape twice.
The domain schema lives in packages/db. The API promise lives in packages/orpc.
With that split, the repo gets typed procedures, runtime validation, known error shapes, OpenAPI generation, and client utilities. The contract package does not need to own the data model.
The data layer is replaceable
The contract does not care where the data comes from.
The contract does not care where the data comes from. That is the point.
Today it is static product data. Later it can be a database, a CMS, a search service, or another API. Only the server implementation should care. The web app, mobile app, OpenAPI spec, and client code should change only when the API contract changes.
Implementing the contract in Hono
The server imports the contract and implements it.
import { implement } from "@orpc/server";
import { contract } from "@repo/orpc";
const os = implement(contract);Each handler now has to satisfy the contract.
const listProducts = base.products.list.handler(({ input }) => {
return filterProducts(input);
});A product lookup can return a known contract error instead of inventing a one-off response shape.
const byId = base.products.byId.handler(({ input, errors }) => {
const product = findProductById(input.id);
if (!product) {
throw errors.NOT_FOUND({
message: `Product ${input.id} not found.`,
});
}
return product;
});The reservation mutation follows the same rule.
const reserve = base.products.reserve.handler(({ input, errors }) => {
const product = findProductById(input.productId);
if (!product) {
throw errors.NOT_FOUND({
message: `Product ${input.productId} not found.`,
});
}
if (!product.inStock) {
throw errors.CONFLICT({
message: `${product.name} is currently out of stock.`,
});
}
return {
id: `res_${Date.now()}`,
product,
quantity: input.quantity,
status: "reserved",
reservedAt: new Date().toISOString(),
};
});Finally, the router mirrors the contract shape.
export const router = base.router({
categories: {
list: listCategories,
},
products: {
list: listProducts,
byId,
bySlug,
reserve,
},
});At this point, the contract is doing real work.
If the contract says a handler returns a product, the implementation cannot casually return a different shape. If the input schema changes, the handler sees that change. If the client sends the wrong payload, the boundary rejects it.
RPC, REST, OpenAPI, and docs from one router
The server exposes multiple surfaces from the same router.
const rpcHandler = new RPCHandler(router, {
plugins: [new CORSPlugin()],
});
const openAPIHandler = new OpenAPIHandler(router, {
plugins: [new CORSPlugin(), new experimental_ZodSmartCoercionPlugin()],
});The app serves:
/rpc -> typed oRPC client calls
/api -> REST/OpenAPI-style calls
/spec.json -> generated OpenAPI spec
/docs -> Scalar API reference
/health -> health checkThat matters because not every consumer wants the same interface.
The Next.js and Expo apps can use typed RPC. External consumers can use REST and OpenAPI. Humans can explore the API through Scalar.
Get Mustafa Elsayed’s stories in your inbox
Join Medium for free to get updates from this writer.
They all come from the same contract.
OpenAPI and Scalar
OpenAPI is generated directly from the implemented router.
const openAPIGenerator = new OpenAPIGenerator({
schemaConverters: [new ZodToJsonSchemaConverter()],
});
app.get("/spec.json", async (c) => {
return c.json(await generateSpec(c.req.raw));
});Scalar loads that generated spec.
<script
id="api-reference"
data-url="/spec.json"
data-theme="deepSpace"
src="https://cdn.jsdelivr.net/npm/@scalar/api-reference"
></script>Once the server is running, the docs are available at http://localhost:4001/docs .
No separate endpoint table. No manual request examples waiting to go stale. The docs are generated from the same API shape the server implements and the clients consume.
Sharing the client
The contract package also exports a typed client factory.
import type { ContractRouterClient } from "@orpc/contract";
import { createORPCClient } from "@orpc/client";
import { RPCLink } from "@orpc/client/fetch";
import { createTanstackQueryUtils } from "@orpc/tanstack-query";
import type { AppContract } from "./contract";
export type AppClient = ContractRouterClient<AppContract>;
export function createAppClient(rpcUrl: string): AppClient {
const link = new RPCLink({ url: rpcUrl });
return createORPCClient<AppClient>(link);
}
export function createAppQueryUtils(client: AppClient) {
return createTanstackQueryUtils(client);
}Every client gets the same API surface.
The Next.js app and Expo app do not need separate SDKs. They import helpers from @repo/orpc and get the same procedure names, inputs, outputs, and errors.
Using oRPC in Next.js
The web app creates the client once.
import { createAppClient, createAppQueryUtils } from "@repo/orpc";
const client = createAppClient(
process.env.NEXT_PUBLIC_RPC_URL ?? "http://localhost:4001/rpc",
);
export const orpc = createAppQueryUtils(client);Then the page can query products with TanStack Query.
const productsQuery = useQuery(
orpc.products.list.queryOptions({
input: {
category,
search,
limit: 12,
},
}),
);The mutation is typed too.
const reserveMutation = useMutation(
orpc.products.reserve.mutationOptions({
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: orpc.products.key(),
});
},
}),
);This removes a common frontend problem: duplicate API types.
There is no separate ProductResponse interface in the web app. There is no handwritten fetch wrapper slowly drifting away from the backend. Query options, mutation input, response data, and invalidation keys all come from the shared contract-aware client.
Why TanStack Query fits here
TanStack Query is already good at managing server state: loading states, caching, refetching, mutations, retries, and invalidation.
The messy part in many apps is the API layer around it.
Without a shared contract, teams often write query wrappers by hand. One file knows the URL. Another knows the request payload. Another defines the response type. Another defines the cache key. Over time, those pieces drift.
oRPC makes that layer contract-aware.
orpc.products.list.queryOptions({ input });
orpc.products.reserve.mutationOptions();
orpc.products.key();The query key, input type, output type, and mutation shape come from the same API definition.
That means TanStack Query still does what it does best, but the API calls become harder to misuse.
For full-stack TypeScript apps, that combination is practical: oRPC defines the contract, and TanStack Query manages the client-side lifecycle around it.
Using the same contract in Expo
The mobile app uses the same idea.
const client = createAppClient("http://localhost:4001/rpc");
const orpc = createAppQueryUtils(client);Then it queries products through the same contract.
const productsQuery = useQuery(
orpc.products.list.queryOptions({
input: {
category,
search,
limit: 12,
},
}),
);The UI can be different. The transport and contract stay the same.
Backend, web, mobile, generated docs, and query helpers all depend on one API definition.
Error handling and Sentry
Good API contracts also describe what can go wrong.
They should also describe what can go wrong.
oRPC supports two styles of error handling.
The first is the normal approach: throw errors directly. You can throw a standard JavaScript error, but ORPCError is usually clearer because it carries an explicit error code and optional client-safe data.
import { ORPCError } from "@orpc/server";
const rateLimit = os.middleware(async ({ next }) => {
throw new ORPCError("RATE_LIMITED", {
message: "You are being rate limited.",
data: { retryAfter: 60 },
});
return next();
});If you throw a regular Error, oRPC can still handle it, but it becomes a generic internal server error. That is fine for unexpected failures. For expected API states, explicit errors are better.
One rule matters: do not put sensitive data in ORPCError.data. That data can be sent to the client.
The second style is type-safe error handling. The product catalog uses this for expected product errors. The contract defines known errors with .errors(…), then handlers throw through the generated errors helper.
export const ProductNotFoundError = {
NOT_FOUND: {
message: "Product not found.",
},
} as const;
export const ProductUnavailableError = {
CONFLICT: {
message: "Product is not available.",
},
} as const;The contract attaches those errors to procedures.
reserve: oc.route({
method: "POST",
path: "/products/{productId}/reservations",
summary: "Reserve product stock.",
successStatus: 201,
})
.errors({
...ProductNotFoundError,
...ProductUnavailableError,
})
.input(ReserveProductInputSchema)
.output(ReservationSchema);Then the server throws typed errors from the handler.
const reserve = base.products.reserve.handler(({ input, errors }) => {
const product = findProductById(input.productId);
if (!product) {
throw errors.NOT_FOUND({
message: `Product ${input.productId} not found.`,
});
}
if (!product.inStock) {
throw errors.CONFLICT({
message: `${product.name} is currently out of stock.`,
});
}
return {
id: `res_${Date.now()}`,
product,
quantity: input.quantity,
status: "reserved",
reservedAt: new Date().toISOString(),
};
});This gives clients a better contract. They know the success shape and the expected failure cases.
You can also combine both approaches. If an ORPCError matches a defined error code, status, and data shape, oRPC treats it like the type-safe error.
Sentry and OpenTelemetry
The server wires Sentry without putting logging code in every handler.
The repo already has @orpc/otel installed. Sentry uses ORPCInstrumentation to collect oRPC tracing information.
Sentry.init({
dsn: Bun.env.SENTRY_DSN,
tracesSampleRate: Number(Bun.env.SENTRY_TRACES_SAMPLE_RATE ?? 1),
openTelemetryInstrumentations: [new ORPCInstrumentation()],
});Tracing is useful, but Sentry does not currently collect every OpenTelemetry span event as an application error. Business logic errors should still be captured manually.
Middleware is a clean place for that.
const sentryMiddleware = os.middleware(async ({ next }) => {
try {
return await next();
} catch (error) {
Sentry.captureException(error);
throw error;
}
});
const base = os.use(sentryMiddleware);Business logic stays in the handler. Error capture stays in middleware. Tracing stays in instrumentation.
The example repo keeps one primary Sentry capture path: procedure middleware. You can also add onError interceptors at the RPC or OpenAPI transport edge, but then you should deduplicate events so the same exception does not show up twice in Sentry.
For production systems, there are two extra details worth knowing:
- oRPC middleware gets its own OpenTelemetry span, so you can add attributes or events to the active span when needed.
- Some errors can happen before your procedure handler runs, such as adapter-level failures or invalid WebSocket messages. Capture uncaught exceptions and unhandled rejections at the process level if your runtime needs that visibility.
Running the project
Prerequisite: install Bun. This repo was created with bun@1.3.10.
bun install
bun run dev
/**
* http://localhost:4001 -> Server
* http://localhost:3000 -> Web
* http://localhost:4001/docs -> Docs
*/Why this pattern works
Contract-first development gives the team one API source of truth.
With this setup:
- The web & mobile apps consumes the contract.
- OpenAPI documentation is generated from the contract.
- Scalar renders documentation from the generated spec.
- TanStack Query options are derived from the typed client.
- Known errors are part of the API design instead of being hidden in route handlers.
That is the boundary this example is trying to protect.
It also scales without tying clients to the data layer. Today, the data layer is static TypeScript data. Later, it can be Drizzle, Prisma, PostgreSQL, SQLite, or an external service. The contract does not need to change unless the API contract changes.
That separation is the point.
Final thought
A type-safe API gives you more than editor hints. It gives your team a boundary everyone can trust.
That matters even more when teams move fast. AI can help build full-stack TypeScript features quickly, but speed without boundaries creates drift. A shared oRPC contract gives humans and AI tools the same source of truth.
The backend knows what to implement. The web and mobile apps know what to call. The docs know what to show. TypeScript knows what changed.
That is the real win: faster development without pushing stability to later.
That is the practical win: faster development without pushing stability to later.
Define the contract once. Then let every runtime prove it still follows that contract.
Clone the example repo, run it locally, and open the generated Scalar docs to see the same contract power the server, clients, OpenAPI spec, and UI:
#orpc #typescript #fullstack #ai #turbo #nextjs #honojs #react #expo






