trpc
tRPC v11 Patterns for Next.js Applications
Purpose
Provide comprehensive tRPC v11 implementation patterns for Next.js 16 applications emphasizing thin routers with command-query separation. Focus on RBAC enforcement, central error mapping, type safety through inference, and proper client/server boundaries.
When To Use This Skill
Router & Procedure Creation:
- Create new tRPC routers for domain entities
- Implement query procedures for data fetching
- Implement mutation procedures for state changes
- Set up public vs protected (RBAC) procedures
Validation & Type Safety:
- Define Zod input schemas with .strict() validation
- Define Zod output schemas for type inference
- Export RouterInputs/RouterOutputs types for UI
- Implement type-safe server callers for RSC
RBAC & Security:
- Enforce authentication with rbacProcedure
- Implement fine-grained permission checks
- Validate user roles and permissions
- Handle authorization failures consistently
Error Handling:
- Map domain errors to tRPC error codes
- Use central error mapping (avoid inline TRPCError)
- Handle NotFoundError, ValidationError, ConflictError
- Capture errors with proper context for Sentry
Client Integration:
- Use tRPC server caller in Server Components
- Set up React Query hooks in Client Components
- Implement optimistic updates
- Configure cache invalidation strategies
Architecture:
- Separate business logic into commands
- Keep routers thin (validate → delegate → map errors)
- Maintain clear server/client boundaries
- Follow command-query separation principle
Core Architecture
Separation of Concerns
UI Component → tRPC Hook → Router → Command → Database
↓ ↓ ↓
Type Safety Validation Logic
RBAC Errors
Error Map
Commands contain pure business logic:
- Export Zod input/output schemas
- Accept typed input (already validated)
- Throw domain errors
- Return typed domain data
- NO parsing, NO RBAC, NO TRPCError
Routers are thin orchestrators:
- Validate with
.input(Schema.strict()) - Enforce RBAC with
rbacProcedure - Delegate to command
- Map domain errors to TRPC errors
Key Principles
- Thin Routers - Validate, enforce RBAC, delegate, map errors
- RBAC at Boundary - Permission checks ONLY in routers
- Central Error Mapping - NO inline
new TRPCError() - Strict Validation - Always
.input(Schema.strict()) - Command Delegation - Business logic lives in commands
Procedures and Middleware
Procedure Types
publicProcedure - No auth required (health, landing pages)
export const publicProcedure = t.procedure.use(loggingMiddleware);
rbacProcedure - Protected, enforces auth + active status
export const rbacProcedure = publicProcedure.use(enforceRBAC);
RBAC Middleware
const enforceRBAC = t.middleware(({ ctx, next }) => {
if (!ctx.userId) throw new TRPCError({ code: "UNAUTHORIZED" });
if (!ctx.dbUserId) throw new TRPCError({ code: "UNAUTHORIZED" });
if (ctx.isInactive) throw new TRPCError({ code: "FORBIDDEN" });
return next({ ctx: { ...ctx, userId: ctx.userId, dbUserId: ctx.dbUserId } });
});
Fine-grained permissions use helpers:
const requirePermission = (ctx: PermissionContext) => {
assertMasterDataPermission(
ctx,
PERMISSIONS.HAULER_VIEW,
"Missing permission",
);
};
Context Structure
Available in all procedures:
userId- Clerk user ID (string)dbUserId- Internal user UUID (string)userRole- Single role (legacy)userRoles- Array of roles (current)isInactive- User deactivated flagdb- Drizzle clientlogger- Pino loggerrequestId,ipAddress- Request metadata
Implementing Procedures
List Query Pattern
Step 1: Define Command (src/lib/api/commands/queries/<domain>/list-<entity>.query.ts)
import { z } from "zod";
import { createSelectSchema } from "drizzle-zod";
import { and, desc, eq, ilike, count } from "drizzle-orm";
// Input schema with pagination, filters, sort
export const ListHaulersInputSchema = z
.object({
page: z.number().int().min(1).default(1),
pageSize: z.number().int().min(1).max(100).default(25),
search: z.string().optional(),
status: haulerStatusSchema.optional(),
sortBy: z.enum(["code", "name", "createdAt"]).default("createdAt"),
sortOrder: z.enum(["asc", "desc"]).default("desc"),
})
.strict();
// Output schema - MUST include normalized pagination shape
export const ListHaulersOutputSchema = z
.object({
items: z.array(haulerSelectSchema),
page: z.number().int().min(1),
pageSize: z.number().int().min(1),
totalCount: z.number().int().nonnegative(),
totalPages: z.number().int().nonnegative(),
})
.strict();
export type ListHaulersInput = z.infer<typeof ListHaulersInputSchema>;
export type ListHaulersOutput = z.infer<typeof ListHaulersOutputSchema>;
export async function listHaulersQuery(
{ db }: QueryContext,
input: ListHaulersInput,
): Promise<ListHaulersOutput> {
const { page, pageSize, search, status, sortBy, sortOrder } = input;
// Build where conditions
const whereConditions = [];
if (search) {
whereConditions.push(
or(
ilike(haulers.code, `%${search}%`),
ilike(haulers.name, `%${search}%`),
),
);
}
if (status) whereConditions.push(eq(haulers.status, status));
const whereClause =
whereConditions.length > 0 ? and(...whereConditions) : undefined;
// Determine sort
const sortColumn = haulers[sortBy];
const orderCondition =
sortOrder === "asc" ? asc(sortColumn) : desc(sortColumn);
// Execute paginated query
const items = await db
.select(haulerListSelection) // Explicit column selection
.from(haulers)
.where(whereClause)
.orderBy(orderCondition)
.limit(pageSize)
.offset((page - 1) * pageSize);
// Count total
const [totalResult] = await db
.select({ count: count() })
.from(haulers)
.where(whereClause);
const totalCount = Number(totalResult?.count ?? 0);
const totalPages = totalCount === 0 ? 0 : Math.ceil(totalCount / pageSize);
return { items, page, pageSize, totalCount, totalPages };
}
Step 2: Wire Router (src/lib/api/routers/<domain>.router.ts)
export const haulersRouter = createTRPCRouter({
list: rbacProcedure
.input(ListHaulersInputSchema)
.output(ListHaulersOutputSchema)
.query(async ({ ctx, input }) => {
try {
requireMasterDataViewPermission(ctx);
return await listHaulersQuery({ db: ctx.db }, input);
} catch (err) {
return handleMasterDataError(err, {
ctx,
entity: "hauler",
action: "list",
});
}
}),
});
Mutation Pattern
Step 1: Define Command (src/lib/api/commands/mutations/<domain>/<action>.mutation.ts)
import { z } from "zod";
import { eq } from "drizzle-orm";
import { NotFoundError, InvalidStackStatusError } from "@/lib/errors/AppError";
export const CertifyStackInputSchema = z
.object({
stackId: z.string().uuid(),
override: z
.object({
reason: z.string().min(10).max(500),
})
.optional(),
})
.strict();
export const CertifyStackOutputSchema = z
.object({
stackId: z.string().uuid(),
status: z.literal("certified"),
certifiedAt: z.string(),
})
.strict();
export type CertifyStackInput = z.infer<typeof CertifyStackInputSchema>;
export type CertifyStackOutput = z.infer<typeof CertifyStackOutputSchema>;
export async function certifyStackMutation(
ctx: MutationContext,
input: CertifyStackInput,
): Promise<CertifyStackOutput> {
const { db, userId } = ctx;
const { stackId, override } = input;
return await db.transaction(async (tx) => {
// Fetch current state
const [currentStack] = await tx
.select({
id: stacks.id,
status: stacks.status,
verifiedBy: stacks.verifiedBy,
})
.from(stacks)
.where(eq(stacks.id, stackId))
.limit(1);
if (!currentStack) throw new NotFoundError("Stack");
if (currentStack.status !== "verified") {
throw new InvalidStackStatusError(currentStack.status, "verified");
}
// Enforce SoD
if (currentStack.verifiedBy === userId) {
throw new SeparationOfDutiesViolationError("certify", "verify", userId);
}
// Update stack
const now = new Date();
const [certifiedStack] = await tx
.update(stacks)
.set({
status: "certified",
certifiedAt: now,
certifiedBy: userId,
updatedAt: now,
updatedBy: userId,
})
.where(eq(stacks.id, stackId))
.returning({
id: stacks.id,
status: stacks.status,
certifiedAt: stacks.certifiedAt,
});
// Audit trail
await tx.insert(stackAudits).values({
stackId,
action: "certified",
actorUserId: userId,
occurredAt: now,
beforeJsonb: { status: "verified" },
afterJsonb: { status: "certified", certifiedAt: now.toISOString() },
});
return {
stackId: certifiedStack.id,
status: "certified",
certifiedAt: certifiedStack.certifiedAt!.toISOString(),
};
});
}
Step 2: Wire Router
export const stacksRouter = createTRPCRouter({
certify: rbacProcedure
.input(CertifyStackInputSchema)
.output(CertifyStackOutputSchema)
.mutation(async ({ ctx, input }) => {
try {
requireStackManagePermission(ctx);
return await certifyStackMutation(ctx, input);
} catch (err) {
return handleStackError(err, {
ctx,
action: "certify",
payload: input,
});
}
}),
});
Error Handling
Domain Errors
Commands throw domain-specific errors from src/lib/errors/AppError.ts:
NotFoundError- Entity not foundValidationError- Business rule violationInvalidStackStatusError- Invalid state transitionSeparationOfDutiesViolationError- SoD violationConflictError- Duplicate entity
Central Error Mapping
Routers use mapAppErrorToTRPC (or domain-specific wrappers):
// Generic mapping
try {
return await command(ctx, input);
} catch (err) {
throw mapAppErrorToTRPC(err);
}
// Domain-specific wrapper
return handleMasterDataError(err, {
ctx,
entity: "hauler",
action: "create",
payload: input,
});
Never throw inline TRPCError:
// ❌ Bad - inline error
throw new TRPCError({ code: 'NOT_FOUND', message: 'Stack not found' })
// ✅ Good - domain error in command
throw new NotFoundError('Stack')
// ✅ Good - mapped in router
catch (err) { throw mapAppErrorToTRPC(err) }
Client Usage
Server Caller in RSC
Server components use direct async calls:
// app/[locale]/(auth)/dashboard/stacks/page.tsx
import { api } from '@/lib/api/server'
export default async function StacksPage() {
const stacks = await api().stacks.list({ page: 1, pageSize: 20 })
return (
<div>
<h1>Stacks ({stacks.totalCount})</h1>
{stacks.items.map(stack => (
<div key={stack.id}>{stack.code}</div>
))}
</div>
)
}
React Query Hooks
Client components use tRPC hooks:
'use client'
import { api } from '@/lib/api/react'
export function StacksListClient() {
const { data, isLoading } = api.stacks.list.useQuery({ page: 1, pageSize: 20 })
if (isLoading) return <div>Loading...</div>
return (
<div>
{data.items.map(stack => (
<div key={stack.id}>{stack.code}</div>
))}
</div>
)
}
Mutations with Cache Invalidation
'use client'
import { api } from '@/lib/api/react'
import { useQueryClient } from '@tanstack/react-query'
export function CertifyStackButton({ stackId }: { stackId: string }) {
const queryClient = useQueryClient()
const certify = api.stacks.certify.useMutation({
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: api.stacks.list.queryKey() })
queryClient.invalidateQueries({ queryKey: api.stacks.get.queryKey({ id: stackId }) })
}
})
return (
<button onClick={() => certify.mutate({ stackId })}>Certify</button>
)
}
Type Inference
RouterOutputs
UI derives types from router outputs:
import type { RouterOutputs } from "@/lib/api/root";
// Infer list item type
type Stack = RouterOutputs["stacks"]["list"]["items"][number];
// Infer single entity type
type StackDetail = RouterOutputs["stacks"]["get"];
Never import DB types in UI:
// ❌ Bad - importing DB schema
import type { Stack } from "@/lib/db/schema";
// ✅ Good - deriving from router
type Stack = RouterOutputs["stacks"]["list"]["items"][number];
Common Mistakes
- Validating in Commands - Commands receive pre-validated input
- RBAC in Commands - Permission checks belong in routers
- Inline TRPCError - Always use central error mapping
- Missing .strict() - Reject extra properties with
.input(Schema.strict()) - Leaking DB Types - UI derives from RouterOutputs
- Inconsistent List Shape - Always return
{ items, page, pageSize, totalCount, totalPages } - Cache Hints in Commands - Commands return domain data only
Quick Templates
Router Procedure
export const domainRouter = createTRPCRouter({
action: rbacProcedure
.input(ActionInputSchema)
.output(ActionOutputSchema)
.mutation(async ({ ctx, input }) => {
try {
requirePermission(ctx);
return await actionCommand(ctx, input);
} catch (err) {
return handleDomainError(err, {
ctx,
action: "action",
payload: input,
});
}
}),
});
Command
export const ActionInputSchema = z
.object({
/* ... */
})
.strict();
export const ActionOutputSchema = z
.object({
/* ... */
})
.strict();
export type ActionInput = z.infer<typeof ActionInputSchema>;
export type ActionOutput = z.infer<typeof ActionOutputSchema>;
export async function actionCommand(
ctx: MutationContext,
input: ActionInput,
): Promise<ActionOutput> {
// Business logic here
return result;
}
Resources
See references/ for detailed pattern documentation:
patterns.md- Comprehensive pattern referencerecipes.md- Step-by-step implementation guidesexamples.md- Full working examples
Codebase references:
src/lib/api/trpc.ts- tRPC setupsrc/lib/api/routers/- Router implementationssrc/lib/api/commands/- Command implementationssrc/lib/errors/AppError.ts- Domain errorsframework/patterns/trpc-procedures.md- Framework docs