21 KiB
oRPC Backend Usage Guidelines
1. Overview
What is oRPC
oRPC (OpenAPI RPC) is a type-safe RPC framework for TypeScript that provides end-to-end type safety from your backend to frontend. It combines the best aspects of REST APIs and RPC frameworks while generating OpenAPI specifications automatically.
Why oRPC over tRPC or plain REST
| Feature | oRPC | tRPC | REST |
|---|---|---|---|
| Type Safety | End-to-end | End-to-end | Manual |
| OpenAPI Generation | Built-in | Plugin required | Manual |
| HTTP Method Control | Full control | Limited | Full control |
| Learning Curve | Low | Low | Medium |
| Middleware Support | Native | Native | Framework-dependent |
| Schema Validation | Zod native | Zod native | Manual |
Key advantages of oRPC:
- OpenAPI-first: Automatic OpenAPI spec generation for documentation and client generation
- HTTP semantics: Full control over HTTP methods, paths, and tags
- Type inference: Automatic TypeScript types from Zod schemas
- Middleware composition: Chainable middleware for auth, logging, etc.
Project Structure with oRPC
packages/api/
├── orpc/
│ ├── router.ts # Main router composition
│ ├── procedures.ts # Base procedure definitions
│ └── middleware/ # Reusable middleware
│ ├── log-id-middleware.ts
│ └── locale-middleware.ts
├── modules/
│ └── [module]/
│ ├── router.ts # Module router exports
│ ├── types.ts # Zod schemas and TypeScript types
│ └── procedures/ # Individual procedure implementations
│ ├── create-item.ts
│ ├── list-items.ts
│ └── update-item.ts
└── lib/ # Shared utilities
2. Router Setup
Main Router Structure
The main router composes all module routers under a common prefix:
// orpc/router.ts
import type { RouterClient } from "@orpc/server";
import { usersRouter } from "../modules/users/router";
import { itemsRouter } from "../modules/items/router";
import { publicProcedure } from "./procedures";
export const router = publicProcedure
// Prefix for OpenAPI paths
.prefix("/api")
.router({
users: usersRouter,
items: itemsRouter,
// Add more module routers here
});
// Export type for frontend client
export type ApiRouterClient = RouterClient<typeof router>;
Module Router Composition
Each module exports a router object that groups related procedures:
// modules/items/router.ts
import { createItem } from "./procedures/create-item";
import { deleteItem } from "./procedures/delete-item";
import { findItem } from "./procedures/find-item";
import { listItems } from "./procedures/list-items";
import { updateItem } from "./procedures/update-item";
export const itemsRouter = {
list: listItems,
find: findItem,
create: createItem,
update: updateItem,
delete: deleteItem,
// Nested routes are supported
drafts: {
list: listDrafts,
save: saveDraft,
},
};
Base Procedures with Middleware
Define base procedures with common middleware:
// orpc/procedures.ts
import { ORPCError, os } from "@orpc/server";
import { logIdMiddleware } from "./middleware/log-id-middleware";
// Public procedure - no authentication required
export const publicProcedure = os
.$context<{
headers: Headers;
}>()
.use(logIdMiddleware);
// Protected procedure - requires authentication
export const protectedProcedure = publicProcedure.use(
async ({ context, next }) => {
const session = await getSession(context.headers);
if (!session) {
throw new ORPCError("UNAUTHORIZED");
}
return await next({
context: {
session: session.session,
user: session.user,
},
});
},
);
// Admin procedure - requires admin role
export const adminProcedure = protectedProcedure.use(
async ({ context, next }) => {
if (context.user.role !== "admin") {
throw new ORPCError("FORBIDDEN");
}
return await next();
},
);
3. Procedure Definition
Query Procedures (GET-like)
Use GET method for read operations that don't modify data:
// modules/items/procedures/list-items.ts
import { z } from "zod";
import { protectedProcedure } from "../../../orpc/procedures";
// Define input schema
const listItemsInputSchema = z.object({
limit: z.number().min(1).max(100).default(50),
cursor: z.object({
createdAt: z.string(),
id: z.string(),
}).optional(),
filters: z.object({
status: z.enum(["active", "archived"]).optional(),
category: z.string().optional(),
}).optional(),
});
// Define output schema
const listItemsOutputSchema = z.object({
items: z.array(z.object({
id: z.string(),
name: z.string(),
status: z.string(),
createdAt: z.date(),
})),
nextCursor: z.object({
createdAt: z.string(),
id: z.string(),
}).nullable(),
hasMore: z.boolean(),
});
export const listItems = protectedProcedure
.route({
method: "GET",
path: "/items",
tags: ["Items"],
summary: "List items with cursor pagination",
description: "Retrieve a paginated list of items for the current user",
})
.input(listItemsInputSchema)
.output(listItemsOutputSchema)
.handler(async ({ input, context }) => {
const { limit, cursor, filters } = input;
const { user } = context;
// Query implementation
const items = await db.query.items.findMany({
where: { userId: user.id, ...filters },
limit: limit + 1, // Fetch one extra to check hasMore
orderBy: [desc(items.createdAt), desc(items.id)],
});
const hasMore = items.length > limit;
const resultItems = hasMore ? items.slice(0, limit) : items;
return {
items: resultItems,
nextCursor: hasMore && resultItems.length > 0
? {
createdAt: resultItems[resultItems.length - 1].createdAt.toISOString(),
id: resultItems[resultItems.length - 1].id,
}
: null,
hasMore,
};
});
Mutation Procedures (POST/PUT/DELETE-like)
Use POST for create operations, PUT/PATCH for updates, DELETE for removals:
// modules/items/procedures/create-item.ts
import { ORPCError } from "@orpc/client";
import { z } from "zod";
import { protectedProcedure } from "../../../orpc/procedures";
const createItemInputSchema = z.object({
name: z.string().min(1).max(255),
description: z.string().optional(),
categoryId: z.string().optional(),
});
const createItemOutputSchema = z.object({
item: z.object({
id: z.string(),
name: z.string(),
description: z.string().nullable(),
createdAt: z.date(),
}),
});
export const createItem = protectedProcedure
.route({
method: "POST",
path: "/items",
tags: ["Items"],
summary: "Create a new item",
})
.input(createItemInputSchema)
.output(createItemOutputSchema)
.handler(async ({ input, context }) => {
const { name, description, categoryId } = input;
const { user } = context;
// Validate category if provided
if (categoryId) {
const category = await db.query.categories.findFirst({
where: { id: categoryId, userId: user.id },
});
if (!category) {
throw new ORPCError("NOT_FOUND", {
message: "Category not found",
});
}
}
const item = await db.insert(items).values({
name,
description,
categoryId,
userId: user.id,
}).returning();
return { item: item[0] };
});
Update Procedure Example
// modules/items/procedures/update-item.ts
import { ORPCError } from "@orpc/client";
import { z } from "zod";
import { protectedProcedure } from "../../../orpc/procedures";
const updateItemInputSchema = z.object({
itemId: z.string(),
name: z.string().min(1).max(255).optional(),
description: z.string().optional(),
status: z.enum(["active", "archived"]).optional(),
});
export const updateItem = protectedProcedure
.route({
method: "PUT",
path: "/items/{itemId}",
tags: ["Items"],
summary: "Update an item",
})
.input(updateItemInputSchema)
.handler(async ({ input, context }) => {
const { itemId, ...updates } = input;
const { user } = context;
// Verify ownership
const existingItem = await db.query.items.findFirst({
where: { id: itemId },
});
if (!existingItem) {
throw new ORPCError("NOT_FOUND", { message: "Item not found" });
}
if (existingItem.userId !== user.id) {
throw new ORPCError("FORBIDDEN", {
message: "You don't have permission to modify this item",
});
}
const updated = await db.update(items)
.set({ ...updates, updatedAt: new Date() })
.where(eq(items.id, itemId))
.returning();
return { item: updated[0] };
});
Input Validation with Zod
oRPC uses Zod for input validation. Define schemas in a separate types.ts file for reusability:
// modules/items/types.ts
import { z } from "zod";
// Input Schemas
export const createItemInputSchema = z.object({
name: z.string().min(1).max(255),
description: z.string().max(1000).optional(),
tags: z.array(z.string()).max(10).optional(),
});
export const updateItemInputSchema = z.object({
itemId: z.string(),
name: z.string().min(1).max(255).optional(),
description: z.string().max(1000).optional(),
});
export const listItemsInputSchema = z.object({
limit: z.number().int().min(1).max(100).default(50),
cursor: z.object({
createdAt: z.string(),
id: z.string(),
}).optional(),
});
// Output Schemas
export const itemSchema = z.object({
id: z.string(),
name: z.string(),
description: z.string().nullable(),
status: z.enum(["active", "archived"]),
createdAt: z.date(),
updatedAt: z.date(),
});
export const operationResultSchema = z.object({
success: z.boolean(),
});
export const batchOperationResultSchema = z.object({
success: z.boolean(),
successCount: z.number(),
failedCount: z.number(),
failedIds: z.array(z.string()).optional(),
});
// Type exports (inferred from schemas)
export type CreateItemInput = z.infer<typeof createItemInputSchema>;
export type UpdateItemInput = z.infer<typeof updateItemInputSchema>;
export type ListItemsInput = z.infer<typeof listItemsInputSchema>;
export type Item = z.infer<typeof itemSchema>;
export type OperationResult = z.infer<typeof operationResultSchema>;
4. Middleware
Authentication Middleware
Built into protectedProcedure:
// orpc/procedures.ts
export const protectedProcedure = publicProcedure.use(
async ({ context, next }) => {
const session = await getSession(context.headers);
if (!session) {
throw new ORPCError("UNAUTHORIZED");
}
// Add user info to context for downstream handlers
return await next({
context: {
session: session.session,
user: session.user,
},
});
},
);
Logging Middleware
Generate and propagate request IDs for tracing:
// orpc/middleware/log-id-middleware.ts
import { os } from "@orpc/server";
function generateLogId(): string {
return `${Date.now()}-${Math.random().toString(36).substring(2, 15)}`;
}
function getOrGenerateLogId(headers: Headers): string {
// Prefer client-provided x-log-id for distributed tracing
const existingLogId = headers.get("x-log-id");
if (existingLogId) {
return existingLogId;
}
return generateLogId();
}
export const logIdMiddleware = os
.$context<{
headers: Headers;
}>()
.middleware(async ({ context, next }) => {
const logId = getOrGenerateLogId(context.headers);
// Run with tracing context
return await runWithTrace(logId, async () => {
return await next({
context: {
logId,
},
});
});
});
Locale Middleware
Extract locale from cookies for i18n:
// orpc/middleware/locale-middleware.ts
import { os } from "@orpc/server";
import { getCookie } from "@orpc/server/helpers";
import { config } from "@your-app/config";
import type { Locale } from "@your-app/i18n";
export const localeMiddleware = os
.$context<{
headers: Headers;
}>()
.middleware(async ({ context, next }) => {
const locale = (getCookie(
context.headers,
config.i18n.localeCookieName,
) as Locale) ?? config.i18n.defaultLocale;
return await next({
context: {
locale,
},
});
});
Using Middleware in Procedures
Apply middleware to specific procedures:
// modules/contact/procedures/submit-contact-form.ts
import { localeMiddleware } from "../../../orpc/middleware/locale-middleware";
import { publicProcedure } from "../../../orpc/procedures";
export const submitContactForm = publicProcedure
.route({
method: "POST",
path: "/contact",
tags: ["Contact"],
summary: "Submit contact form",
})
.input(contactFormSchema)
.use(localeMiddleware) // Apply locale middleware
.handler(async ({ input, context: { locale } }) => {
// locale is now available in context
await sendEmail({
to: config.contactForm.to,
locale,
subject: config.contactForm.subject,
text: `Name: ${input.name}\n\nEmail: ${input.email}\n\nMessage: ${input.message}`,
});
});
Error Handling Middleware
Create custom error handling:
// orpc/middleware/error-middleware.ts
import { ORPCError, os } from "@orpc/server";
import { logger } from "@your-app/logs";
export const errorMiddleware = os.middleware(async ({ next, path }) => {
try {
return await next();
} catch (error) {
// Log error with context
logger.error("Procedure error", {
path,
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
});
// Re-throw oRPC errors as-is
if (error instanceof ORPCError) {
throw error;
}
// Wrap unknown errors
throw new ORPCError("INTERNAL_SERVER_ERROR", {
message: "An unexpected error occurred",
});
}
});
5. Context
How to Access User Session
The session is available in context after protectedProcedure:
export const getProfile = protectedProcedure
.route({ method: "GET", path: "/users/profile", tags: ["Users"] })
.handler(async ({ context }) => {
// context.user contains the authenticated user
const { user, session } = context;
return {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
sessionId: session.id,
};
});
How to Access Logger
Use the logger from the shared logs package:
import { logger } from "@your-app/logs";
export const createItem = protectedProcedure
.route({ method: "POST", path: "/items", tags: ["Items"] })
.input(createItemInputSchema)
.handler(async ({ input, context }) => {
logger.info("Creating item", {
userId: context.user.id,
itemName: input.name,
});
try {
const item = await db.insert(items).values({
...input,
userId: context.user.id,
}).returning();
logger.info("Item created successfully", { itemId: item[0].id });
return { item: item[0] };
} catch (error) {
logger.error("Failed to create item", {
userId: context.user.id,
error: error instanceof Error ? error.message : String(error),
});
throw new ORPCError("INTERNAL_SERVER_ERROR", {
message: "Failed to create item",
});
}
});
How to Access Database
Import the database client and use it directly:
import { db } from "@your-app/database";
import { items, categories } from "@your-app/database/drizzle/schema";
import { eq, and, desc } from "drizzle-orm";
export const listItems = protectedProcedure
.route({ method: "GET", path: "/items", tags: ["Items"] })
.handler(async ({ context }) => {
// Using Drizzle query builder
const userItems = await db.query.items.findMany({
where: eq(items.userId, context.user.id),
orderBy: desc(items.createdAt),
with: {
category: true, // Include relations
},
});
// Or using raw select
const itemsWithCategory = await db
.select({
id: items.id,
name: items.name,
categoryName: categories.name,
})
.from(items)
.leftJoin(categories, eq(items.categoryId, categories.id))
.where(eq(items.userId, context.user.id));
return { items: userItems };
});
6. Best Practices
Input/Output Schema Naming Conventions
Follow consistent naming patterns:
// Input schemas: [action][Entity]InputSchema
export const createItemInputSchema = z.object({ ... });
export const updateItemInputSchema = z.object({ ... });
export const listItemsInputSchema = z.object({ ... });
export const deleteItemInputSchema = z.object({ ... });
// Output schemas: [action][Entity]OutputSchema or [entity]Schema
export const itemSchema = z.object({ ... });
export const listItemsOutputSchema = z.object({ ... });
export const operationResultSchema = z.object({ ... });
// Shared/reusable schemas: [entity]Schema or [concept]Schema
export const paginationSchema = z.object({
limit: z.number().int().min(1).max(100).default(50),
cursor: z.object({
createdAt: z.string(),
id: z.string(),
}).optional(),
});
Error Handling Patterns
Use appropriate error codes and messages:
import { ORPCError } from "@orpc/client";
// Resource not found
throw new ORPCError("NOT_FOUND", {
message: "Item not found",
});
// Permission denied
throw new ORPCError("FORBIDDEN", {
message: "You don't have permission to access this resource",
});
// Authentication required
throw new ORPCError("UNAUTHORIZED", {
message: "Please sign in to continue",
});
// Validation error (usually handled by Zod, but for custom validation)
throw new ORPCError("BAD_REQUEST", {
message: "Invalid email format",
});
// Conflict (e.g., duplicate entry)
throw new ORPCError("CONFLICT", {
message: "An item with this name already exists",
});
// Server error (wrap internal errors)
try {
await externalService.call();
} catch (error) {
logger.error("External service failed", { error });
throw new ORPCError("INTERNAL_SERVER_ERROR", {
message: "Service temporarily unavailable",
});
}
Procedure Organization
- One procedure per file: Keep procedures focused and testable
- Group related procedures: Use module routers to organize by domain
- Reuse schemas: Define common schemas in
types.ts - Consistent file naming: Use kebab-case matching the procedure name
modules/items/
├── router.ts # Exports all procedures
├── types.ts # Shared schemas and types
└── procedures/
├── create-item.ts # createItem procedure
├── delete-item.ts # deleteItem procedure
├── find-item.ts # findItem procedure
├── list-items.ts # listItems procedure
└── update-item.ts # updateItem procedure
Performance Tips
- Use cursor pagination instead of offset for large datasets
- Batch database queries to avoid N+1 problems
- Add appropriate indexes for filtered/sorted columns
- Use select to fetch only needed columns
// Avoid N+1 queries - fetch related data in batch
const items = await db.query.items.findMany({
where: eq(items.userId, user.id),
limit,
});
// Batch fetch labels for all items
const itemIds = items.map(i => i.id);
const labels = await db.query.itemLabels.findMany({
where: inArray(itemLabels.itemId, itemIds),
});
// Group labels by itemId
const labelsByItemId = new Map();
for (const label of labels) {
const existing = labelsByItemId.get(label.itemId) || [];
existing.push(label);
labelsByItemId.set(label.itemId, existing);
}
// Combine results
const itemsWithLabels = items.map(item => ({
...item,
labels: labelsByItemId.get(item.id) || [],
}));
Testing Procedures
Structure tests to cover various scenarios:
import { describe, expect, it } from "vitest";
import { createCaller } from "../test-utils";
describe("createItem", () => {
it("creates an item successfully", async () => {
const caller = createCaller({ user: testUser });
const result = await caller.items.create({
name: "Test Item",
description: "A test item",
});
expect(result.item.name).toBe("Test Item");
expect(result.item.id).toBeDefined();
});
it("throws UNAUTHORIZED for unauthenticated users", async () => {
const caller = createCaller({ user: null });
await expect(
caller.items.create({ name: "Test" })
).rejects.toThrow("UNAUTHORIZED");
});
it("validates input schema", async () => {
const caller = createCaller({ user: testUser });
await expect(
caller.items.create({ name: "" }) // Empty name
).rejects.toThrow();
});
});