Docs /ai-agents/examples/trpc-endpoint

description: Example of creating tRPC endpoints with best practices version: 1.0.0 lastUpdated: 2025-01-10 tags: [example, trpc, api, endpoint]

Example: tRPC Endpoint

This document demonstrates how to create type-safe tRPC endpoints following best practices.


Basic Query Endpoint

// File: packages/trpc/src/routers/projects.ts
import { router, protectedProcedure } from '../trpc';
import { z } from 'zod';
import { projects } from '@izri/database/schema';
import { eq } from 'drizzle-orm';

export const projectsRouter = router({
  // GET endpoint - fetch single project by ID
  getById: protectedProcedure
    .input(z.object({
      id: z.string().ulid(), // ULID validation
    }))
    .query(async ({ ctx, input }) => {
      const project = await ctx.db.query.projects.findFirst({
        where: eq(projects.id, input.id),
      });
      
      if (!project) {
        throw new TRPCError({
          code: 'NOT_FOUND',
          message: 'Project not found',
        });
      }
      
      return project;
    }),
});

Query with Pagination

export const projectsRouter = router({
  list: protectedProcedure
    .input(z.object({
      limit: z.number().min(1).max(100).default(10),
      offset: z.number().min(0).default(0),
      search: z.string().optional(),
      sortBy: z.enum(['name', 'createdAt', 'updatedAt']).default('createdAt'),
      sortOrder: z.enum(['asc', 'desc']).default('desc'),
    }))
    .query(async ({ ctx, input }) => {
      const { limit, offset, search, sortBy, sortOrder } = input;
      
      // Build query
      const query = ctx.db
        .select()
        .from(projects)
        .where(eq(projects.userId, ctx.user.id));
      
      // Add search filter
      if (search) {
        query.where(ilike(projects.name, `%${search}%`));
      }
      
      // Add sorting
      const orderByColumn = projects[sortBy];
      query.orderBy(sortOrder === 'asc' ? asc(orderByColumn) : desc(orderByColumn));
      
      // Add pagination
      query.limit(limit).offset(offset);
      
      // Execute query
      const results = await query;
      
      // Get total count for pagination
      const [{ count }] = await ctx.db
        .select({ count: sql<number>`count(*)` })
        .from(projects)
        .where(eq(projects.userId, ctx.user.id));
      
      return {
        items: results,
        total: count,
        hasMore: offset + limit < count,
      };
    }),
});

Frontend Usage:

const { data } = trpc.projects.list.useQuery({
  limit: 10,
  offset: 0,
  search: 'my project',
  sortBy: 'name',
  sortOrder: 'asc',
});

console.log(data?.items); // Project[]
console.log(data?.total); // number
console.log(data?.hasMore); // boolean

Mutation Endpoint

export const projectsRouter = router({
  create: protectedProcedure
    .input(z.object({
      name: z.string().min(1).max(255),
      description: z.string().max(1000).optional(),
      githubUrl: z.string().url().optional(),
    }))
    .mutation(async ({ ctx, input }) => {
      // Check if project with same name exists
      const existing = await ctx.db.query.projects.findFirst({
        where: and(
          eq(projects.userId, ctx.user.id),
          eq(projects.name, input.name)
        ),
      });
      
      if (existing) {
        throw new TRPCError({
          code: 'CONFLICT',
          message: 'A project with this name already exists',
        });
      }
      
      // Create project
      const [project] = await ctx.db
        .insert(projects)
        .values({
          ...input,
          userId: ctx.user.id,
        })
        .returning();
      
      return project;
    }),
});

Frontend Usage:

const createProject = trpc.projects.create.useMutation({
  onSuccess: (newProject) => {
    console.log('Created:', newProject);
    // Invalidate project list
    trpc.useUtils().projects.list.invalidate();
  },
  onError: (error) => {
    if (error.data?.code === 'CONFLICT') {
      toast.error('Project name already exists');
    }
  },
});

// Call mutation
createProject.mutate({
  name: 'My Project',
  description: 'A cool project',
});

Complex Query with Relations

export const projectsRouter = router({
  getWithDetails: protectedProcedure
    .input(z.object({
      id: z.string().ulid(), // ULID validation
      includeTests: z.boolean().default(false),
      includeTags: z.boolean().default(false),
    }))
    .query(async ({ ctx, input }) => {
      const project = await ctx.db.query.projects.findFirst({
        where: eq(projects.id, input.id),
        with: {
          user: {
            columns: {
              id: true,
              name: true,
              email: true,
            },
          },
          ...(input.includeTests && {
            tests: {
              limit: 10,
              orderBy: desc(tests.createdAt),
            },
          }),
          ...(input.includeTags && {
            tags: true,
          }),
        },
      });
      
      if (!project) {
        throw new TRPCError({
          code: 'NOT_FOUND',
          message: 'Project not found',
        });
      }
      
      // Authorization check
      if (project.userId !== ctx.user.id && !project.isPublic) {
        throw new TRPCError({
          code: 'FORBIDDEN',
          message: 'Access denied',
        });
      }
      
      return project;
    }),
});

Batch Operations

export const projectsRouter = router({
  deleteMany: protectedProcedure
    .input(z.object({
      ids: z.array(z.string().ulid()).min(1).max(50), // ULID validation
    }))
    .mutation(async ({ ctx, input }) => {
      // Verify ownership of all projects
      const projectsToDelete = await ctx.db
        .select()
        .from(projects)
        .where(
          and(
            inArray(projects.id, input.ids),
            eq(projects.userId, ctx.user.id)
          )
        );
      
      if (projectsToDelete.length !== input.ids.length) {
        throw new TRPCError({
          code: 'FORBIDDEN',
          message: 'Cannot delete projects you do not own',
        });
      }
      
      // Delete in transaction
      await ctx.db.transaction(async (tx) => {
        await tx
          .delete(projects)
          .where(inArray(projects.id, input.ids));
      });
      
      return {
        success: true,
        deletedCount: input.ids.length,
      };
    }),
});

File Upload Endpoint

export const projectsRouter = router({
  uploadLogo: protectedProcedure
    .input(z.object({
      projectId: z.string().ulid(), // ULID validation
      fileData: z.string(), // Base64 encoded
      fileName: z.string(),
      mimeType: z.string(),
    }))
    .mutation(async ({ ctx, input }) => {
      // Verify project ownership
      const project = await ctx.db.query.projects.findFirst({
        where: eq(projects.id, input.projectId),
      });
      
      if (!project || project.userId !== ctx.user.id) {
        throw new TRPCError({
          code: 'FORBIDDEN',
          message: 'Access denied',
        });
      }
      
      // Validate file
      if (!input.mimeType.startsWith('image/')) {
        throw new TRPCError({
          code: 'BAD_REQUEST',
          message: 'Only image files are allowed',
        });
      }
      
      const maxSize = 5 * 1024 * 1024; // 5MB
      const fileSize = Buffer.from(input.fileData, 'base64').length;
      
      if (fileSize > maxSize) {
        throw new TRPCError({
          code: 'BAD_REQUEST',
          message: 'File size exceeds 5MB limit',
        });
      }
      
      // Upload to storage (S3, Cloudinary, etc.)
      const logoUrl = await uploadToStorage({
        data: input.fileData,
        fileName: input.fileName,
        mimeType: input.mimeType,
      });
      
      // Update project
      const [updated] = await ctx.db
        .update(projects)
        .set({ logoUrl })
        .where(eq(projects.id, input.projectId))
        .returning();
      
      return updated;
    }),
});

Webhook/Async Job Endpoint

export const projectsRouter = router({
  analyzeRepository: protectedProcedure
    .input(z.object({
      projectId: z.string().ulid(), // ULID validation
      repositoryUrl: z.string().url(),
    }))
    .mutation(async ({ ctx, input }) => {
      // Verify ownership
      const project = await ctx.db.query.projects.findFirst({
        where: eq(projects.id, input.projectId),
      });
      
      if (!project || project.userId !== ctx.user.id) {
        throw new TRPCError({
          code: 'FORBIDDEN',
          message: 'Access denied',
        });
      }
      
      // Create analysis job
      const [job] = await ctx.db
        .insert(analysisJobs)
        .values({
          projectId: input.projectId,
          repositoryUrl: input.repositoryUrl,
          status: 'pending',
        })
        .returning();
      
      // Queue background job (e.g., BullMQ)
      await ctx.jobQueue.add('analyze-repository', {
        jobId: job.id,
        projectId: input.projectId,
        repositoryUrl: input.repositoryUrl,
      });
      
      return {
        jobId: job.id,
        status: 'pending',
        message: 'Repository analysis started',
      };
    }),
  
  // Poll job status
  getAnalysisStatus: protectedProcedure
    .input(z.object({
      jobId: z.string().ulid(), // ULID validation
    }))
    .query(async ({ ctx, input }) => {
      const job = await ctx.db.query.analysisJobs.findFirst({
        where: eq(analysisJobs.id, input.jobId),
        with: {
          project: true,
        },
      });
      
      if (!job || job.project.userId !== ctx.user.id) {
        throw new TRPCError({
          code: 'FORBIDDEN',
          message: 'Access denied',
        });
      }
      
      return {
        status: job.status,
        progress: job.progress,
        result: job.result,
        error: job.error,
      };
    }),
});

Frontend Usage (Polling):

const { data: status } = trpc.projects.getAnalysisStatus.useQuery(
  { jobId: 'job-id' },
  {
    refetchInterval: (data) => {
      // Stop polling when complete
      return data?.status === 'completed' || data?.status === 'failed'
        ? false
        : 2000; // Poll every 2 seconds
    },
  }
);

Subscription Endpoint (WebSocket)

import { observable } from '@trpc/server/observable';

export const projectsRouter = router({
  onUpdate: protectedProcedure
    .input(z.object({
      projectId: z.string().ulid(), // ULID validation
    }))
    .subscription(async ({ ctx, input }) => {
      // Verify access
      const project = await ctx.db.query.projects.findFirst({
        where: eq(projects.id, input.projectId),
      });
      
      if (!project || project.userId !== ctx.user.id) {
        throw new TRPCError({
          code: 'FORBIDDEN',
          message: 'Access denied',
        });
      }
      
      return observable<Project>((emit) => {
        // Subscribe to project updates (e.g., using Redis Pub/Sub)
        const subscription = ctx.pubsub.subscribe(
          `project:${input.projectId}`,
          (data) => {
            emit.next(data);
          }
        );
        
        // Cleanup
        return () => {
          subscription.unsubscribe();
        };
      });
    }),
});

Frontend Usage:

trpc.projects.onUpdate.useSubscription(
  { projectId: 'project-id' },
  {
    onData: (project) => {
      console.log('Project updated:', project);
    },
  }
);

Error Handling Patterns

Custom Error with Details

throw new TRPCError({
  code: 'BAD_REQUEST',
  message: 'Validation failed',
  cause: {
    fields: {
      name: 'Name is required',
      email: 'Invalid email format',
    },
  },
});

Error with Original Error

try {
  await externalAPI.call();
} catch (error) {
  throw new TRPCError({
    code: 'INTERNAL_SERVER_ERROR',
    message: 'External API call failed',
    cause: error, // Include original error
  });
}

Conditional Error Codes

if (!user) {
  throw new TRPCError({
    code: 'NOT_FOUND',
    message: 'User not found',
  });
}

if (user.id !== ctx.user.id) {
  throw new TRPCError({
    code: 'FORBIDDEN',
    message: 'Access denied',
  });
}

if (user.status === 'suspended') {
  throw new TRPCError({
    code: 'FORBIDDEN',
    message: 'Account suspended',
  });
}

Middleware Usage

Rate Limiting

import { rateLimit } from '../middleware/rateLimit';

export const projectsRouter = router({
  create: protectedProcedure
    .use(rateLimit({ max: 10, windowMs: 60000 })) // 10 per minute
    .input(createProjectSchema)
    .mutation(async ({ ctx, input }) => {
      // ... implementation
    }),
});

Logging

import { loggingMiddleware } from '../middleware/logging';

export const projectsRouter = router({
  getById: protectedProcedure
    .use(loggingMiddleware)
    .input(z.object({ id: z.string().ulid() })) // ULID validation
    .query(async ({ ctx, input }) => {
      // ... implementation
    }),
});

Caching

import { cache } from '../middleware/cache';

export const projectsRouter = router({
  getById: protectedProcedure
    .use(cache({ ttl: 300 })) // Cache for 5 minutes
    .input(z.object({ id: z.string().ulid() })) // ULID validation
    .query(async ({ ctx, input }) => {
      // ... implementation
    }),
});

Testing tRPC Endpoints

import { describe, it, expect, beforeEach } from 'vitest';
import { createTestContext } from '../test-utils';
import { appRouter } from '../root';

describe('projects.getById', () => {
  let ctx: TestContext;
  let caller: ReturnType<typeof appRouter.createCaller>;
  
  beforeEach(async () => {
    ctx = await createTestContext({ authenticated: true });
    caller = appRouter.createCaller(ctx);
  });
  
  it('should return project by id', async () => {
    const created = await caller.projects.create({
      name: 'Test Project',
    });
    
    const project = await caller.projects.getById({
      id: created.id,
    });
    
    expect(project).toMatchObject({
      id: created.id,
      name: 'Test Project',
    });
  });
  
  it('should throw NOT_FOUND for invalid id', async () => {
    await expect(
      caller.projects.getById({ id: 'invalid-ulid-string-here' })
    ).rejects.toThrow('NOT_FOUND');
  });
  
  it('should throw FORBIDDEN for other user\'s project', async () => {
    const user1Ctx = await createTestContext({ authenticated: true });
    const user1Caller = appRouter.createCaller(user1Ctx);
    
    const project = await user1Caller.projects.create({
      name: 'User 1 Project',
    });
    
    const user2Ctx = await createTestContext({ authenticated: true });
    const user2Caller = appRouter.createCaller(user2Ctx);
    
    await expect(
      user2Caller.projects.getById({ id: project.id })
    ).rejects.toThrow('FORBIDDEN');
  });
});

Best Practices

✅ Do

  1. Validate all inputs with Zod schemas
  2. Check authorization before database queries
  3. Use appropriate error codes
  4. Return consistent response shapes
  5. Add pagination to list endpoints
  6. Use transactions for multi-step operations
  7. Test all endpoints thoroughly

❌ Don't

  1. Don't use any types
  2. Don't skip input validation
  3. Don't expose sensitive data
  4. Don't return passwords or tokens
  5. Don't ignore error handling
  6. Don't create N+1 query problems

Summary

tRPC Endpoint Checklist:

  • Input validation with Zod
  • Authorization checks
  • Proper error codes
  • Database queries optimized
  • Return type defined
  • Tests written
  • Documentation added

Version: 1.0.0
Last Updated: 2025-01-10

Reading this with an agent? /docs/ai-agents/examples/trpc-endpoint.md serves the raw markdown.

All docs