GameCraftGameCraft

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 client

Basic 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 protectedProcedure for auth - Never trust frontend
  • Return typed objects - Avoid any types
  • 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


Resources

On this page