---
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

```typescript
// 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

```typescript
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**:

```typescript
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

```typescript
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**:

```typescript
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

```typescript
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

```typescript
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

```typescript
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

```typescript
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)**:

```typescript
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)

```typescript
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**:

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

---

## Error Handling Patterns

### Custom Error with Details

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

### Error with Original Error

```typescript
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

```typescript
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

```typescript
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

```typescript
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

```typescript
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

```typescript
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