tRPC API Guide
Build type-safe APIs with tRPC v11 - complete guide with examples
tRPC API Guide
ProductReady uses tRPC v11 for building type-safe APIs. This means you get full auto-completion, type safety, and runtime validation from backend to frontend - no code generation needed!
Why tRPC? Write your API once, get TypeScript types automatically on the frontend. No manual type definitions, no sync issues.
What is tRPC?
tRPC lets you build APIs that are:
- Type-safe - Frontend knows exactly what the backend returns
- Auto-complete everywhere - Your editor helps you write code
- Validated - Input/output checked at runtime with Zod
- Fast to build - No REST boilerplate, no GraphQL schemas
Example: Create an API endpoint in 5 lines, use it on frontend with full types!
Project Structure
src/
├── server/
│ ├── trpc.ts # tRPC setup & middleware
│ ├── context/ # Request context (user, db)
│ └── routers/ # API endpoints
│ ├── index.ts # Main router (combines all)
│ ├── agentTasks.ts # Tasks CRUD
│ └── posts.ts # Posts CRUD
├── app/
│ └── api/
│ └── trpc/
│ └── [trpc]/route.ts # Next.js API route handler
└── lib/
└── trpc/
└── client.tsx # Frontend tRPC clientBasic Concepts
1. Procedures
Procedures are your API endpoints. Two types:
- Query - Fetch data (GET)
- Mutation - Change data (POST/PUT/DELETE)
// Query example
export const tasksRouter = createTRPCRouter({
// GET /api/trpc/agentTasks.list
list: publicProcedure.query(async ({ ctx }) => {
return ctx.db.select().from(agentTasks);
}),
// POST /api/trpc/agentTasks.create
create: protectedProcedure
.input(z.object({ title: z.string() }))
.mutation(async ({ ctx, input }) => {
return ctx.db.insert(agentTasks).values(input);
}),
});2. Context
Context is data available to all procedures (user session, database, etc.):
// src/server/context/index.ts
export async function createContext({ req, res }: CreateNextContextOptions) {
const session = await getSession(req, res);
return {
session, // Current user
db, // Database client
};
}3. Middleware
Middleware runs before procedures (auth checks, logging, etc.):
// src/server/trpc.ts
const isAuthed = t.middleware(({ ctx, next }) => {
if (!ctx.session?.user) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
return next({
ctx: {
session: ctx.session, // Now TypeScript knows session exists!
},
});
});
// Use it in procedures
export const protectedProcedure = t.procedure.use(isAuthed);Your First API Endpoint
Let's build a "Projects" feature from scratch!
Create database schema
Create src/db/schema/projects.ts:
import { pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core';
import { users } from './users';
export const projects = pgTable('projects', {
id: uuid('id').defaultRandom().primaryKey(),
name: text('name').notNull(),
description: text('description'),
ownerId: text('owner_id').references(() => users.id).notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
});Create tRPC router
Create src/server/routers/projects.ts:
import { z } from 'zod';
import { createTRPCRouter, protectedProcedure } from '../trpc';
import { projects } from '~/db/schema/projects';
import { eq } from 'drizzle-orm';
export const projectsRouter = createTRPCRouter({
// List user's projects
list: protectedProcedure.query(async ({ ctx }) => {
return ctx.db
.select()
.from(projects)
.where(eq(projects.ownerId, ctx.session.user.id));
}),
// Get single project
get: protectedProcedure
.input(z.object({ id: z.string().uuid() }))
.query(async ({ ctx, input }) => {
const project = await ctx.db
.select()
.from(projects)
.where(eq(projects.id, input.id))
.limit(1);
if (!project[0]) {
throw new TRPCError({ code: 'NOT_FOUND' });
}
return project[0];
}),
// Create project
create: protectedProcedure
.input(z.object({
name: z.string().min(1).max(100),
description: z.string().max(500).optional(),
}))
.mutation(async ({ ctx, input }) => {
const [project] = await ctx.db
.insert(projects)
.values({
...input,
ownerId: ctx.session.user.id,
})
.returning();
return project;
}),
// Update project
update: protectedProcedure
.input(z.object({
id: z.string().uuid(),
name: z.string().min(1).max(100).optional(),
description: z.string().max(500).optional(),
}))
.mutation(async ({ ctx, input }) => {
const { id, ...data } = input;
const [updated] = await ctx.db
.update(projects)
.set({ ...data, updatedAt: new Date() })
.where(eq(projects.id, id))
.returning();
return updated;
}),
// Delete project
delete: protectedProcedure
.input(z.object({ id: z.string().uuid() }))
.mutation(async ({ ctx, input }) => {
await ctx.db
.delete(projects)
.where(eq(projects.id, input.id));
return { success: true };
}),
});Register router
Add to src/server/routers/index.ts:
import { projectsRouter } from './projects';
export const appRouter = createTRPCRouter({
agentTasks: tasksRouter,
posts: postsRouter,
projects: projectsRouter, // ← Add this
});Use on frontend
Now use it in React components with full type safety:
'use client';
import { trpc } from '~/lib/trpc/client';
export function ProjectsList() {
// Full auto-completion! ✨
const { data: projects, isLoading } = trpc.projects.list.useQuery();
const createMutation = trpc.projects.create.useMutation({
onSuccess: () => {
// Refetch projects list
utils.projects.list.invalidate();
},
});
async function handleCreate() {
await createMutation.mutateAsync({
name: 'My New Project',
description: 'Built with ProductReady',
});
}
if (isLoading) return <div>Loading...</div>;
return (
<div>
<button onClick={handleCreate}>Create Project</button>
{projects?.map((project) => (
<div key={project.id}>
<h3>{project.name}</h3>
<p>{project.description}</p>
</div>
))}
</div>
);
}Done! You now have a fully type-safe CRUD API with zero code generation. Frontend knows all types automatically!
Common Patterns
Pagination
export const projectsRouter = createTRPCRouter({
list: protectedProcedure
.input(z.object({
cursor: z.number().default(0),
limit: z.number().min(1).max(100).default(20),
}))
.query(async ({ ctx, input }) => {
const items = await ctx.db
.select()
.from(projects)
.limit(input.limit + 1)
.offset(input.cursor);
let nextCursor: number | undefined;
if (items.length > input.limit) {
items.pop();
nextCursor = input.cursor + input.limit;
}
return {
items,
nextCursor,
};
}),
});Frontend usage with infinite scroll:
const { data, fetchNextPage, hasNextPage } =
trpc.projects.list.useInfiniteQuery(
{ limit: 20 },
{ getNextPageParam: (lastPage) => lastPage.nextCursor }
);Error Handling
import { TRPCError } from '@trpc/server';
export const projectsRouter = createTRPCRouter({
get: protectedProcedure
.input(z.object({ id: z.string().uuid() }))
.query(async ({ ctx, input }) => {
const project = await ctx.db
.select()
.from(projects)
.where(eq(projects.id, input.id))
.limit(1);
if (!project[0]) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Project not found',
});
}
// Check ownership
if (project[0].ownerId !== ctx.session.user.id) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'You do not own this project',
});
}
return project[0];
}),
});Frontend error handling:
const { data, error } = trpc.projects.get.useQuery({ id: projectId });
if (error) {
if (error.data?.code === 'NOT_FOUND') {
return <div>Project not found</div>;
}
if (error.data?.code === 'FORBIDDEN') {
return <div>Access denied</div>;
}
return <div>Error: {error.message}</div>;
}Optimistic Updates
Make UI feel instant:
const utils = trpc.useUtils();
const updateMutation = trpc.projects.update.useMutation({
onMutate: async (newData) => {
// Cancel outgoing refetches
await utils.projects.list.cancel();
// Snapshot previous value
const previousProjects = utils.projects.list.getData();
// Optimistically update
utils.projects.list.setData(undefined, (old) =>
old?.map((p) =>
p.id === newData.id ? { ...p, ...newData } : p
)
);
return { previousProjects };
},
onError: (err, newData, context) => {
// Rollback on error
utils.projects.list.setData(undefined, context?.previousProjects);
},
onSettled: () => {
// Refetch to sync
utils.projects.list.invalidate();
},
});Advanced Features
Subscriptions (Real-time)
Listen to database changes in real-time:
import { observable } from '@trpc/server/observable';
export const projectsRouter = createTRPCRouter({
onUpdate: protectedProcedure
.input(z.object({ projectId: z.string().uuid() }))
.subscription(({ ctx, input }) => {
return observable<Project>((emit) => {
const interval = setInterval(() => {
// Check for updates (use websockets in production)
ctx.db
.select()
.from(projects)
.where(eq(projects.id, input.projectId))
.then((result) => {
if (result[0]) {
emit.next(result[0]);
}
});
}, 5000);
return () => clearInterval(interval);
});
}),
});Batch Requests
tRPC automatically batches multiple queries:
// These run in parallel as a single HTTP request! 🚀
const projects = trpc.projects.list.useQuery();
const agentTasks = trpc.agentTasks.list.useQuery();
const posts = trpc.posts.list.useQuery();Testing
Test tRPC routers without HTTP:
import { createCaller } from '~/server/routers';
test('creates project', async () => {
const caller = createCaller({
session: { user: { id: 'user123' } },
db: mockDb,
});
const project = await caller.projects.create({
name: 'Test Project',
});
expect(project.name).toBe('Test Project');
expect(project.ownerId).toBe('user123');
});Best Practices
✅ Do
- Use Zod for input validation - Runtime safety
- Keep routers small - One router per domain (users, projects, etc.)
- Use
protectedProcedurefor auth - Never trust frontend - Return typed objects - Avoid
anytypes - Handle errors explicitly - Use TRPCError codes
// ✅ Good
export const projectsRouter = createTRPCRouter({
create: protectedProcedure
.input(CreateProjectSchema)
.mutation(async ({ ctx, input }) => {
// Typed input, typed return
return ctx.db.insert(projects).values(input).returning();
}),
});❌ Don't
- Don't skip input validation - Always use
.input() - Don't return database models directly - Use VOs (View Objects)
- Don't put business logic in middleware - Keep it in procedures
- Don't expose internal IDs - Use UUIDs or obfuscated IDs
// ❌ Bad
export const projectsRouter = createTRPCRouter({
create: publicProcedure // ← No auth check!
.mutation(async ({ ctx, input }) => { // ← No input validation!
return ctx.db.insert(projects).values(input as any); // ← 'any' type!
}),
});Troubleshooting
"Property does not exist on type"
Issue: Frontend doesn't see new endpoint.
Fix: Restart dev server to regenerate types:
pnpm dev"UNAUTHORIZED" error
Issue: Protected procedure requires login.
Fix: Check session middleware in src/server/trpc.ts:
const isAuthed = t.middleware(async ({ ctx, next }) => {
console.log('Session:', ctx.session); // ← Debug
if (!ctx.session?.user) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
return next({ ctx });
});Input validation fails
Issue: Zod schema rejects valid input.
Fix: Check schema in router:
.input(z.object({
name: z.string().min(1), // ← Ensure this matches your input
}))Test schema separately:
import { CreateProjectSchema } from './router';
console.log(CreateProjectSchema.parse({ name: 'Test' }));Next Steps
- OpenAPI REST API - External REST API with Hono
- Database Guide - Schema design and queries
- Authentication - Protect your endpoints
- Testing Guide - Write tests for your APIs
Resources
- tRPC Docs: trpc.io/docs
- Example Routers:
src/server/routers/ - Zod Validation: zod.dev
- React Query (used by tRPC): tanstack.com/query