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
- Validate all inputs with Zod schemas
- Check authorization before database queries
- Use appropriate error codes
- Return consistent response shapes
- Add pagination to list endpoints
- Use transactions for multi-step operations
- Test all endpoints thoroughly
❌ Don't
- Don't use
anytypes - Don't skip input validation
- Don't expose sensitive data
- Don't return passwords or tokens
- Don't ignore error handling
- 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