TRPC Patterns
Overview
Implement TRPC routers following the project's established patterns for schema definition, custom procedures, middleware, and error handling.
When to Use This Skill
Use this skill when:
-
Creating new TRPC routers
-
Adding procedures to existing routers
-
Building custom middleware for authorization
-
Implementing error handling in TRPC endpoints
-
Need examples of proper TRPC patterns
Core Patterns
- Simple Inline Schemas
For simple validation schemas, define them inline within the procedure. Always use types from @project/common instead of hardcoding values.
Pattern:
import { OrganizationRoles } from "@project/common"; import { z } from "zod";
export const router = { createInvitation: protectedProcedure .input( z.object({ email: z.string().email(), organizationId: z.string().min(1), role: z.enum([OrganizationRoles.Owner, OrganizationRoles.Admin, OrganizationRoles.Member]), }), ) .mutation(async ({ ctx, input }) => { // Implementation }), } satisfies TRPCRouterRecord;
Rules:
-
✅ Inline simple schemas directly in procedure definition
-
✅ Use enum values from @project/common
-
❌ Don't create separate schema constants for simple cases
-
❌ Don't hardcode enum values like ["owner", "admin", "member"]
See references/simple-schemas.md for more examples.
- Custom Procedures for Organization Access
For organization-scoped operations, use protectedMemberAccessProcedure instead of manually checking membership.
Pattern:
export const router = { getOrganizationCredentials: protectedMemberAccessProcedure .input(z.object({ organizationId: z.string() })) .query(async ({ ctx, input }) => { // Organization membership already validated const credentials = await ctx.db.select().from(credentialsTable); // Direct implementation }), } satisfies TRPCRouterRecord;
Available Procedures:
-
publicProcedure
-
No authentication required
-
protectedProcedure
-
Requires authenticated user
-
adminProcedure
-
Requires admin role
-
protectedMemberAccessProcedure
-
Requires organization membership
Rules:
-
✅ Use protectedMemberAccessProcedure for org-scoped operations
-
❌ Don't manually check organization membership in procedures
See references/custom-procedures.md for detailed examples and context enhancement patterns.
- Creating Custom Middleware
Build reusable base procedures using the .use() method for middleware logic.
Pattern:
export const protectedMemberAccessProcedure = protectedProcedure .input(z.object({ organizationId: z.string().min(1) })) .use(async function isMemberOfOrganization(opts) { const { ctx, input } = opts;
const memberAccess = await ctx.db
.select()
.from(membersTable)
.where(
and(
eq(membersTable.organizationId, input.organizationId),
eq(membersTable.userId, ctx.session.user.id),
),
)
.limit(1);
if (!memberAccess.length) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You are not a member of this organization",
});
}
return opts.next({
ctx: {
member: memberAccess[0],
},
});
});
Middleware Rules:
-
✅ Use .use() method to chain middleware functions
-
✅ Always return opts.next() with enhanced context
-
✅ Name middleware functions descriptively
-
✅ Build on existing base procedures
-
❌ Don't create utility function approaches
See references/middleware-patterns.md for advanced middleware examples.
- Error Handling
Always use standardized error helpers from @/infrastructure/errors.ts .
Pattern:
import { badRequestError, unauthorizedError, forbiddenError, notFoundError, } from "@/infrastructure/errors";
export const router = { deleteProject: protectedProcedure .input(z.object({ projectId: z.string() })) .mutation(async ({ ctx, input }) => { const project = await ctx.db .select() .from(projectsTable) .where(eq(projectsTable.id, input.projectId)) .limit(1);
if (!project.length) {
throw notFoundError("Project not found");
}
if (project[0].ownerId !== ctx.session.user.id) {
throw forbiddenError("You don't have permission to delete this project");
}
// Implementation...
}),
} satisfies TRPCRouterRecord;
Available Error Helpers:
-
badRequestError(message)
-
Invalid input or request
-
unauthorizedError(message)
-
Authentication issues
-
forbiddenError(message)
-
Authorization/permission issues
-
notFoundError(message)
-
Missing resources
Rules:
-
✅ Use error helper functions
-
❌ Don't create TRPCError manually
See references/error-handling.md for comprehensive error handling patterns.
Type Inference
IMPORTANT: TRPC types are automatically inferred from backend router definitions through TypeScript's static type system. No code generation is needed.
Pattern:
// Backend: apps/web-app/src/projects/trpc/project.ts export const router = { estimateProjectCreation: protectedProcedure .input(z.object({ organizationId: z.string() })) .query(async ({ ctx, input }) => { return { integrationsCount: 10, estimatedSeconds: 5, }; }), } satisfies TRPCRouterRecord;
// Frontend: Types are automatically inferred via static imports const estimate = trpc.project.estimateProjectCreation.useQuery({ organizationId: "123", }); // TypeScript knows estimate.data has { integrationsCount: number, estimatedSeconds: number }
Type Inference Rules:
-
✅ Types are statically inferred from backend router through TypeScript imports
-
✅ Frontend imports AppRouter type and gets full type safety
-
✅ Server does NOT need to be running for type inference (it's static analysis)
-
✅ Changes to backend router immediately update frontend types on next TypeScript check
-
❌ Don't create manual type definitions for TRPC endpoints
-
❌ Don't expect runtime type generation - it's compile-time only
How It Works:
-
Backend exports AppRouter type from root router
-
Frontend imports AppRouter and creates typed TRPC client
-
TypeScript compiler statically analyzes backend code and infers all types
-
IDE and type checker see changes immediately after file save
If new endpoint doesn't appear:
-
Check if backend router is properly exported in root router
-
Verify TypeScript can resolve the import path
-
Restart TypeScript language server in IDE if needed
SQL Query Optimization
Always prefer single SQL queries with JOINs over multiple separate queries.
Pattern:
// ✅ Good - Single optimized query with joins export const router = { getOrganizationsDetails: protectedProcedure.query(async ({ ctx: { session, db } }) => { const result = await db .select({ organization: organizationsTable, project: projectsTable, integration: organizationIntegrationsTable, userRole: membersTable.role, }) .from(membersTable) .innerJoin(organizationsTable, eq(membersTable.organizationId, organizationsTable.id)) .leftJoin(projectsTable, eq(projectsTable.organizationId, organizationsTable.id)) .where(eq(membersTable.userId, session.user.id));
return groupResults(result);
}), };
SQL Optimization Rules:
-
Use single queries with JOINs instead of multiple queries
-
Only fetch data that's actually needed
-
Use LEFT JOIN for optional relationships, INNER JOIN for required
-
Group/aggregate in SQL when possible, otherwise in application code
Resources
references/
Detailed reference documentation for each pattern:
-
simple-schemas.md
-
Complete examples of inline schema patterns
-
custom-procedures.md
-
Available procedures and context enhancement
-
middleware-patterns.md
-
Advanced middleware creation patterns
-
error-handling.md
-
Comprehensive error handling guide