What are Procedures?
Procedures are the building blocks of your ORPC API. They define endpoints that can be called from your client with full type safety. Each procedure can:
- Accept typed input with validation
- Access request context (session, database, etc.)
- Return typed output
- Use middleware for cross-cutting concerns
The ORPC setup is defined in src/lib/orpc.ts:
import { ORPCError, os } from "@orpc/server"
import type { Context } from "./context"
export const o = os.$context<Context>()
export const publicProcedure = o
export const requireAuth = o.middleware(async ({ context, next }) => {
if (!context.session?.user) {
throw new ORPCError("UNAUTHORIZED")
}
return next({
context: {
session: context.session
}
})
})
export const protectedProcedure = publicProcedure.use(requireAuth)
Public Procedures
Public procedures are accessible without authentication. They’re perfect for endpoints that don’t require a logged-in user.
Basic Public Procedure
Here’s a simple example from src/routers/todo.ts that creates a todo item:
import { publicProcedure } from "../lib/orpc"
import z from "zod"
import { db } from "../db"
import { todo } from "../db/schema/todo"
export const todoRouter = {
create: publicProcedure
.input(z.object({ text: z.string().min(1) }))
.handler(async ({ input }) => {
return await db.insert(todo).values({
text: input.text
})
})
}
Breakdown
.input() - Defines and validates the input schema using Zod
.handler() - Implements the procedure logic
{ input } - The validated input is passed to the handler
More Examples
Toggling a todo’s completion status:
toggle: publicProcedure
.input(z.object({ id: z.number(), completed: z.boolean() }))
.handler(async ({ input }) => {
return await db
.update(todo)
.set({ completed: input.completed })
.where(eq(todo.id, input.id))
})
Deleting a todo:
delete: publicProcedure
.input(z.object({ id: z.number() }))
.handler(async ({ input }) => {
return await db.delete(todo).where(eq(todo.id, input.id))
})
Public procedures still have access to the context, including session data if the user is logged in. However, they don’t require authentication.
Protected Procedures
Protected procedures require authentication. They use the requireAuth middleware to ensure a user is logged in before executing.
Basic Protected Procedure
From src/routers/todo.ts:
import { protectedProcedure } from "../lib/orpc"
export const todoRouter = {
getAll: protectedProcedure.handler(async () => {
return await db.select().from(todo)
})
}
This procedure:
- Requires authentication (enforced by
requireAuth middleware)
- Has no input validation (no
.input() call)
- Returns all todos from the database
Accessing User Data
In protected procedures, you can safely access the authenticated user:
getUserTodos: protectedProcedure.handler(async ({ context }) => {
const userId = context.session.user.id
return await db
.select()
.from(todo)
.where(eq(todo.userId, userId))
})
The requireAuth middleware guarantees that context.session.user exists, providing full type safety.
If an unauthenticated user tries to call a protected procedure, they’ll receive an UNAUTHORIZED error before the handler executes.
ORPC uses Zod for runtime input validation and type inference. This provides both runtime safety and TypeScript types.
Basic Validation
.input(z.object({
text: z.string().min(1)
}))
This validates that:
- Input is an object
- Has a
text property
text is a string with at least 1 character
Complex Validation
You can use any Zod schema:
.input(z.object({
id: z.number().int().positive(),
completed: z.boolean(),
priority: z.enum(["low", "medium", "high"]).optional(),
dueDate: z.string().datetime().optional()
}))
Validation Benefits
- Runtime Safety: Invalid input is rejected before reaching your handler
- Type Inference: TypeScript automatically infers the input type
- Error Messages: Zod provides clear validation error messages
- Client-Side Types: The client knows exactly what data to send
Define reusable Zod schemas for common input shapes to keep your code DRY.
Handler Implementation
The handler is where your procedure logic lives. It receives an object with:
input: The validated input (if .input() was used)
context: The request context (session, database, etc.)
Handler Signature
.handler(async ({ input, context }) => {
// Your logic here
return result
})
Accessing Context
Even if you don’t need input, you can access the context:
getSession: publicProcedure.handler(async ({ context }) => {
return context.session
})
Returning Data
Whatever you return from the handler is sent to the client with full type safety:
getTodo: publicProcedure
.input(z.object({ id: z.number() }))
.handler(async ({ input }) => {
const result = await db
.select()
.from(todo)
.where(eq(todo.id, input.id))
.limit(1)
if (result.length === 0) {
throw new ORPCError("NOT_FOUND")
}
return result[0] // TypeScript knows the return type
})
Type Safety Benefits
One of ORPC’s biggest advantages is end-to-end type safety:
On the Server
create: publicProcedure
.input(z.object({ text: z.string().min(1) }))
.handler(async ({ input }) => {
// input.text is typed as string
// TypeScript will error if you try input.invalidProp
})
On the Client
When you call this procedure from the client:
// TypeScript knows this procedure requires { text: string }
await client.todo.create({ text: "Buy groceries" })
// TypeScript error: missing required property
await client.todo.create({})
// TypeScript error: wrong type
await client.todo.create({ text: 123 })
The return type is also inferred:
const todos = await client.todo.getAll()
// TypeScript knows the shape of todos based on your schema
Procedure Composition
You can build procedures on top of each other:
// Base procedure with logging
const loggedProcedure = publicProcedure.use(
o.middleware(async ({ next }) => {
console.log("Procedure called at", new Date())
return next({})
})
)
// Protected procedure with logging
const protectedLoggedProcedure = loggedProcedure.use(requireAuth)
This allows you to layer middleware for different concerns like logging, rate limiting, caching, and authentication.
Error Handling
Use ORPCError to throw typed errors:
import { ORPCError } from "@orpc/server"
.handler(async ({ input, context }) => {
if (!input.text.trim()) {
throw new ORPCError("BAD_REQUEST", {
message: "Text cannot be empty"
})
}
if (!context.session) {
throw new ORPCError("UNAUTHORIZED")
}
// ...
})
Common error codes:
UNAUTHORIZED - User not authenticated
FORBIDDEN - User lacks permissions
NOT_FOUND - Resource doesn’t exist
BAD_REQUEST - Invalid input
INTERNAL_SERVER_ERROR - Unexpected error
Best Practices
- Use Protected Procedures: Always use
protectedProcedure for operations that require authentication
- Validate Input: Use comprehensive Zod schemas to validate all user input
- Handle Errors: Throw appropriate
ORPCError types for different failure scenarios
- Keep Handlers Focused: Each procedure should do one thing well
- Reuse Schemas: Extract common Zod schemas to constants
- Type Your Returns: Let TypeScript infer return types from your database queries
Next Steps
- Learn about Context and how data flows through procedures
- Explore Middleware to create custom authentication and validation logic
- See how Better Auth provides session management