Create tRPC Router
Step-by-step guide for creating a type-safe tRPC API router with CRUD operations
Overview
This guide walks you through creating a tRPC router with type-safe procedures. tRPC provides end-to-end type safety from server to client without code generation or runtime overhead.
tRPC routers automatically provide TypeScript types to React hooks, eliminating the need for manual API type definitions.
Prerequisites
Before creating a router, gather:
Step 1: Router Name
Choose a descriptive name in camelCase (e.g., notifications, projects, apiKeys)
Step 2: Procedures
List operations needed: list, getById, create, update, upsert, delete, etc.
Step 3: Auth Level
Determine authentication requirements:
- publicProcedure - No auth required
- authProcedure - User must be authenticated
- roleProcedure - Specific role required (admin, etc.)
Step 1: Create Router File
Create src/trpc/procedures/routers/<router-name>.ts:
import {
<feature>DeleteSchema,
<feature>GetSchema,
<feature>UpsertSchema,
} from "@/features/<feature-name>/schema";
import {
authProcedure,
createTRPCRouter,
} from "@/trpc/procedures/trpc";
import type { ActionResponse } from "@/types";
export const <feature>sRouter = createTRPCRouter({
// List all items for current user
list: authProcedure
.meta({ rateLimit: "QUERY" })
.query(async ({ ctx }) => {
const items = await ctx.db.<feature>s.getByOwnerId(ctx.user.id);
return {
success: true,
payload: items,
} satisfies ActionResponse;
}),
// Get single item by ID
getById: authProcedure
.meta({ rateLimit: "QUERY" })
.input(<feature>GetSchema)
.query(async ({ ctx, input }) => {
const item = await ctx.db.<feature>s.get(input.id);
return {
success: true,
payload: item,
} satisfies ActionResponse;
}),
// Create or update (upsert)
upsert: authProcedure
.meta({ rateLimit: "MUTATION" })
.input(<feature>UpsertSchema)
.mutation(async ({ ctx, input }) => {
const { id, ...data } = input;
if (id) {
// Update existing
const updated = await ctx.db.<feature>s.update(id, data);
return {
success: true,
message: ctx.t("toasts.saved"),
payload: updated,
} satisfies ActionResponse;
}
// Create new
const created = await ctx.db.<feature>s.create({
...data,
ownerId: ctx.user.id,
});
return {
success: true,
message: ctx.t("toasts.saved"),
payload: created,
} satisfies ActionResponse;
}),
// Delete
delete: authProcedure
.meta({ rateLimit: "MUTATION" })
.input(<feature>DeleteSchema)
.mutation(async ({ ctx, input }) => {
await ctx.db.<feature>s.remove(input.id);
return {
success: true,
message: ctx.t("toasts.deleted"),
} satisfies ActionResponse;
}),
});Step 2: Register Router
In src/trpc/procedures/root.ts:
import { <feature>sRouter } from "@/trpc/procedures/routers/<feature-name>";
export const appRouter = createTRPCRouter({
// ...existing routers
<feature>s: <feature>sRouter,
});
export type AppRouter = typeof appRouter;Router names must be unique - The name becomes part of the API path: api.<feature>s.list()
Procedure Types
Public Procedure
No authentication required - accessible to everyone:
import { publicProcedure } from "@/trpc/procedures/trpc";
getPublicData: publicProcedure
.meta({ rateLimit: "QUERY" })
.query(async ({ ctx }) => {
const data = await ctx.db.<feature>s.list();
return {
success: true,
payload: data,
} satisfies ActionResponse;
}),Authenticated Procedure
Requires user to be logged in:
import { authProcedure } from "@/trpc/procedures/trpc";
getUserData: authProcedure
.meta({ rateLimit: "QUERY" })
.query(async ({ ctx }) => {
// ctx.user is guaranteed to exist
const data = await ctx.db.<feature>s.getByOwnerId(ctx.user.id);
return {
success: true,
payload: data,
} satisfies ActionResponse;
}),Role-Based Procedure
Requires specific user role:
import { roleProcedure } from "@/trpc/procedures/trpc";
import { UserRole } from "@/features/auth/schema";
adminOnly: roleProcedure(UserRole.ADMIN)
.meta({ rateLimit: "MUTATION" })
.mutation(async ({ ctx, input }) => {
// Only admins can access this
// ctx.user.role === UserRole.ADMIN
return {
success: true,
} satisfies ActionResponse;
}),Rate Limiting
Configure rate limiting per procedure:
.meta({ rateLimit: "QUERY" }) // For read operations
.meta({ rateLimit: "MUTATION" }) // For write operations
.meta({ rateLimit: "AUTH" }) // For auth operations
.meta({ rateLimit: false }) // Disable rate limitingRate limit configurations are in src/trpc/procedures/trpc.ts:
| Type | Requests | Window | Use Case |
|---|---|---|---|
QUERY | 100 | 1 min | Read operations |
MUTATION | 20 | 1 min | Write operations |
AUTH | 5 | 1 min | Auth-related operations |
Input Validation
With Zod Schema
import { z } from "zod";
getById: authProcedure
.input(z.object({ id: z.string().uuid() }))
.query(async ({ ctx, input }) => {
// input is typed and validated
const item = await ctx.db.<feature>s.get(input.id);
return { success: true, payload: item };
}),With Complex Schema
import { <feature>UpsertSchema } from "@/features/<feature-name>/schema";
upsert: authProcedure
.input(<feature>UpsertSchema)
.mutation(async ({ ctx, input }) => {
// input validated against full schema
// ...
}),Multiple Inputs
search: authProcedure
.input(
z.object({
query: z.string().min(1),
page: z.number().int().positive().default(1),
limit: z.number().int().positive().max(100).default(10),
})
)
.query(async ({ ctx, input }) => {
const { query, page, limit } = input;
// ...
}),ActionResponse Pattern
All procedures should return ActionResponse for consistency:
type ActionResponse<T = unknown> = {
success: boolean;
message?: string;
payload?: T;
error?: string;
};Success Response
return {
success: true,
payload: item,
} satisfies ActionResponse;Success with Message
return {
success: true,
message: ctx.t("toasts.saved"),
payload: item,
} satisfies ActionResponse;Error Response
return {
success: false,
error: "Item not found",
} satisfies ActionResponse;Translation Context: Use ctx.t() for internationalized messages in responses.
Context Access
Available in all procedures via ctx:
query(async ({ ctx }) => {
// Database operations
ctx.db.<feature>s.list();
// Current user (authProcedure only)
ctx.user.id;
ctx.user.email;
ctx.user.role;
// Translations
ctx.t("key");
// Session
ctx.session;
// Request headers
ctx.headers;
});OpenAPI Integration (Optional)
Expose procedures as REST endpoints:
list: authProcedure
.meta({
rateLimit: "QUERY",
openapi: {
method: "GET",
path: "/<feature>s",
tags: ["<Feature>s"],
summary: "List all <feature>s",
description: "Get all <feature>s for the current user",
},
})
.input(z.object({})) // Required for OpenAPI
.output(
z.object({
success: z.boolean(),
payload: z.array(<feature>Schema),
})
) // Required for OpenAPI
.query(async ({ ctx }) => {
const items = await ctx.db.<feature>s.getByOwnerId(ctx.user.id);
return {
success: true,
payload: items,
};
}),Access via REST:
GET /api/<feature>s
Authorization: Bearer <token>OpenAPI Requirements:
- Must define
.input()and.output()schemas - Method must be GET, POST, PUT, PATCH, or DELETE
- Path must be unique
Error Handling
Throwing Errors
import { TRPCError } from "@trpc/server";
getById: authProcedure
.input(<feature>GetSchema)
.query(async ({ ctx, input }) => {
const item = await ctx.db.<feature>s.get(input.id);
if (!item) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Item not found",
});
}
return { success: true, payload: item };
}),Error Codes
| Code | HTTP Status | Use Case |
|---|---|---|
BAD_REQUEST | 400 | Invalid input |
UNAUTHORIZED | 401 | Not authenticated |
FORBIDDEN | 403 | No permission |
NOT_FOUND | 404 | Resource doesn't exist |
CONFLICT | 409 | Duplicate or version issue |
PRECONDITION_FAILED | 412 | Condition not met |
PAYLOAD_TOO_LARGE | 413 | Request too large |
TOO_MANY_REQUESTS | 429 | Rate limit exceeded |
INTERNAL_SERVER_ERROR | 500 | Unexpected server error |
Validation Errors
upsert: authProcedure
.input(<feature>UpsertSchema)
.mutation(async ({ ctx, input }) => {
// Zod validation errors are automatically handled
// and returned with proper error messages
}),Advanced Patterns
Pagination
list: authProcedure
.input(
z.object({
page: z.number().int().positive().default(1),
limit: z.number().int().positive().max(100).default(10),
})
)
.query(async ({ ctx, input }) => {
const { page, limit } = input;
const offset = (page - 1) * limit;
const items = await ctx.db.<feature>s.listTable({
offset,
limit,
});
return {
success: true,
payload: items,
meta: {
page,
limit,
total: items.length,
},
};
}),Filtering
list: authProcedure
.input(
z.object({
status: z.enum(["active", "inactive"]).optional(),
search: z.string().optional(),
})
)
.query(async ({ ctx, input }) => {
const { status, search } = input;
let whereClause;
if (status) {
whereClause = eq(<feature>s.status, status);
}
const items = await ctx.db.<feature>s.listFiltered(whereClause);
return { success: true, payload: items };
}),Batch Operations
import { z } from "zod";
deleteMany: authProcedure
.input(z.object({ ids: z.array(z.string().uuid()) }))
.mutation(async ({ ctx, input }) => {
await Promise.all(
input.ids.map((id) => ctx.db.<feature>s.remove(id))
);
return {
success: true,
message: ctx.t("toasts.deleted"),
};
}),Conditional Logic
upsert: authProcedure
.input(<feature>UpsertSchema)
.mutation(async ({ ctx, input }) => {
const { id, ...data } = input;
// Different logic for create vs update
if (id) {
// Verify ownership before update
const existing = await ctx.db.<feature>s.get(id);
if (existing.ownerId !== ctx.user.id) {
throw new TRPCError({ code: "FORBIDDEN" });
}
const updated = await ctx.db.<feature>s.update(id, data);
return { success: true, payload: updated };
}
// Create new with owner
const created = await ctx.db.<feature>s.create({
...data,
ownerId: ctx.user.id,
});
return { success: true, payload: created };
}),Complete CRUD Example
import {
<feature>DeleteSchema,
<feature>GetSchema,
<feature>UpsertSchema,
<feature>Schema,
} from "@/features/<feature-name>/schema";
import {
authProcedure,
createTRPCRouter,
publicProcedure,
} from "@/trpc/procedures/trpc";
import type { ActionResponse } from "@/types";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
export const <feature>sRouter = createTRPCRouter({
// Public - List all
listPublic: publicProcedure
.meta({ rateLimit: "QUERY" })
.query(async ({ ctx }) => {
const items = await ctx.db.<feature>s.list();
return { success: true, payload: items } satisfies ActionResponse;
}),
// Auth - List user's items
list: authProcedure
.meta({ rateLimit: "QUERY" })
.query(async ({ ctx }) => {
const items = await ctx.db.<feature>s.getByOwnerId(ctx.user.id);
return { success: true, payload: items } satisfies ActionResponse;
}),
// Auth - Get by ID
getById: authProcedure
.meta({ rateLimit: "QUERY" })
.input(<feature>GetSchema)
.query(async ({ ctx, input }) => {
const item = await ctx.db.<feature>s.get(input.id);
if (!item) {
throw new TRPCError({ code: "NOT_FOUND" });
}
return { success: true, payload: item } satisfies ActionResponse;
}),
// Auth - Create or update
upsert: authProcedure
.meta({ rateLimit: "MUTATION" })
.input(<feature>UpsertSchema)
.mutation(async ({ ctx, input }) => {
const { id, ...data } = input;
if (id) {
const updated = await ctx.db.<feature>s.update(id, data);
return {
success: true,
message: ctx.t("toasts.saved"),
payload: updated,
} satisfies ActionResponse;
}
const created = await ctx.db.<feature>s.create({
...data,
ownerId: ctx.user.id,
});
return {
success: true,
message: ctx.t("toasts.saved"),
payload: created,
} satisfies ActionResponse;
}),
// Auth - Delete
delete: authProcedure
.meta({ rateLimit: "MUTATION" })
.input(<feature>DeleteSchema)
.mutation(async ({ ctx, input }) => {
await ctx.db.<feature>s.remove(input.id);
return {
success: true,
message: ctx.t("toasts.deleted"),
} satisfies ActionResponse;
}),
// Auth - Search with filters
search: authProcedure
.meta({ rateLimit: "QUERY" })
.input(
z.object({
query: z.string().optional(),
page: z.number().int().positive().default(1),
limit: z.number().int().positive().max(100).default(10),
})
)
.query(async ({ ctx, input }) => {
const { query, page, limit } = input;
const offset = (page - 1) * limit;
const items = await ctx.db.<feature>s.listTable({
offset,
limit,
});
return {
success: true,
payload: items,
meta: { page, limit },
} satisfies ActionResponse;
}),
});Post-Creation Checklist
-
Router File
- Router file created in
src/trpc/procedures/routers/ - All procedures defined
- Input schemas validated
- Output uses ActionResponse
- Router file created in
-
Registration
- Router exported from file
- Router added to
root.ts - No TypeScript errors
-
Security
- Correct procedure type used (public/auth/role)
- Rate limiting configured
- Owner verification in mutations
- Error handling implemented
-
Testing
- Procedures callable from client
- Input validation works
- Auth checks enforced
- Error responses correct
Real-World Examples
See these routers for reference:
- src/trpc/procedures/routers/api-keys.ts - Full CRUD with RLS
- src/trpc/procedures/routers/organizations.ts - Complex relationships
- src/trpc/procedures/routers/auth.ts - Public procedures
Router created! Your type-safe API is ready. Client hooks will automatically have full TypeScript support.