Router Patterns
Standard patterns for tRPC routers including queries, mutations, pagination, filtering, and the architecture test.
All feature routers follow consistent patterns. This page documents those patterns with real examples from the codebase.
Adding a New Router
- Create
packages/api/src/routers/kebab-case.ts - Export
const camelCaseRouter = router({ ... }) - Register in
packages/api/src/routers/index.ts(barrel export) - Register in
packages/api/src/router.ts(add toappRouter)
If the router introduces a new domain entity, also add a CASL subject -- see Ability Definitions for the steps.
The Standard Query Pattern
Every feature query follows this structure: check CASL, then query using ctx.db.
// From packages/api/src/routers/organization.ts
export const organizationRouter = router({
detail: authorizedProcedure.query(async ({ ctx }) => {
if (ctx.ability.cannot("read", "Organization")) {
throw new TRPCError({ code: "FORBIDDEN" });
}
const org = await ctx.db.query.organization.findFirst({
where: eq(organization.id, ctx.organizationId),
});
if (!org) {
throw new TRPCError({ code: "NOT_FOUND", message: "Organization not found" });
}
return org;
}),
});
Key points:
- CASL check comes first, before any database query
- Queries use
ctx.dbwhich is already RLS-scoped -- never filter byorganization_idmanually - Not-found resources throw
NOT_FOUNDwith a descriptive message - The return value is inferred by TypeScript and flows to the client type-safely
The Standard Mutation Pattern
Mutations validate input via Zod schemas (typically from @repo/validators), check CASL, then write.
// From packages/api/src/routers/organization.ts
update: authorizedProcedure
.input(updateOrganizationSchema)
.mutation(async ({ ctx, input }) => {
if (ctx.ability.cannot("update", "Organization")) {
throw new TRPCError({ code: "FORBIDDEN" });
}
const [updated] = await ctx.db
.update(organization)
.set({
...(input.name !== undefined && { name: input.name }),
...(input.slug !== undefined && { slug: input.slug }),
})
.where(eq(organization.id, ctx.organizationId))
.returning();
if (!updated) {
throw new TRPCError({ code: "NOT_FOUND", message: "Organization not found" });
}
return updated;
}),
Key points:
- Input schemas come from
@repo/validators(shared between API and client forms) - tRPC runs the Zod schema automatically -- invalid input returns a structured error before the handler runs
- Mutations use
.returning()to get the updated record and confirm the write succeeded - Partial updates use spread with
undefinedchecks to only set changed fields
Paginated List Pattern
Most list endpoints accept limit, offset, and optional filters. They return both the items and the total count for pagination.
// From packages/api/src/routers/research-plan.ts
list: authorizedProcedure
.input(
z.object({
limit: z.number().min(1).max(100).default(20),
offset: z.number().min(0).default(0),
status: z.enum(["planning", "executing", "awaiting_review", "stalled", "completed", "failed"])
.optional(),
}),
)
.query(async ({ ctx, input }) => {
if (ctx.ability.cannot("read", "ResearchPlan")) {
throw new TRPCError({ code: "FORBIDDEN" });
}
const conditions = input.status ? [eq(researchPlan.status, input.status)] : [];
const where = conditions.length > 0 ? and(...conditions) : undefined;
const plans = await ctx.db
.select()
.from(researchPlan)
.where(where)
.orderBy(desc(researchPlan.updatedAt))
.limit(input.limit)
.offset(input.offset);
const [totalRow] = await ctx.db
.select({ total: count() })
.from(researchPlan)
.where(where);
return { plans, total: totalRow?.total ?? 0 };
}),
The pattern for building filter conditions:
- Start with an empty array
- Push conditions for each non-undefined filter
- Combine with
and(...conditions)if any exist, otherwise passundefined(no WHERE clause)
Some routers use Promise.all to run the items query and count query in parallel:
// From packages/api/src/routers/research-artifact.ts
const [rows, totalRows] = await Promise.all([
ctx.db
.select(/* ... */)
.from(researchArtifact)
.where(where)
.orderBy(desc(researchArtifact.createdAt))
.limit(input.limit)
.offset(input.offset),
ctx.db.select({ count: count() }).from(researchArtifact).where(where),
]);
ABAC (Attribute-Based) Checks
Some operations require checking attributes of the target resource beyond what CASL provides. These checks are written as explicit if statements after loading the target.
// From packages/api/src/routers/member.ts -- members can remove themselves
remove: authorizedProcedure.input(removeMemberSchema).mutation(async ({ ctx, input }) => {
const target = await ctx.db.query.member.findFirst({
where: eq(member.id, input.memberId),
});
if (!target) {
throw new TRPCError({ code: "NOT_FOUND", message: "Member not found" });
}
const isSelf = target.userId === ctx.session.user.id;
if (ctx.ability.cannot("delete", "Member") && !isSelf) {
throw new TRPCError({ code: "FORBIDDEN" });
}
if (target.role === "owner") {
throw new TRPCError({
code: "FORBIDDEN",
message: "Cannot remove the organization owner",
});
}
await ctx.db.delete(member).where(eq(member.id, input.memberId));
return { success: true };
}),
For a detailed discussion of when RBAC vs ABAC checks are appropriate, see Router Enforcement.
User-Scoped Routers (protectedProcedure)
The PAT router is the only router that uses protectedProcedure instead of authorizedProcedure. PATs are user-level resources, not organization-scoped, so they do not need tenant context or CASL authorization.
// From packages/api/src/routers/pat.ts
export const patRouter = router({
list: protectedProcedure.query(async ({ ctx }) => {
return listUserPATs(ctx.session.user.id);
}),
create: protectedProcedure.input(createPatSchema).mutation(async ({ ctx, input }) => {
// ... generates token, hashes it, stores in DB
return { id, token: rawToken };
}),
});
Using a lower-privilege procedure requires an entry in the architecture test allowlist (see below).
Architecture Test Enforcement
A Vitest test in packages/api/src/__tests__/arch.test.ts scans every file in src/routers/ and verifies it uses authorizedProcedure. Files that intentionally use a different procedure must be listed in AUTHORIZED_PROCEDURE_ALLOWLIST with a justification:
const AUTHORIZED_PROCEDURE_ALLOWLIST: Record<string, string> = {
"pat.ts": "PAT management is user-level, not org-scoped — uses protectedProcedure",
"index.ts": "Barrel re-export file",
"ai-logs-helpers.ts": "Pure helper functions, no procedure definitions",
};
The test fails if any non-allowlisted file uses publicProcedure., protectedProcedure., or tenantProcedure. at a call site. This runs in CI alongside unit tests.
Adding entries to the allowlist is flagged for manual review. See Router Enforcement for the full details on how this works and the review expectations.
Banned Imports
ESLint enforces that routers use context-provided services instead of importing directly:
| Banned Import | Use Instead |
|---|---|
import { db } from "@repo/db" | ctx.db |
import { ... } from "@repo/logger" | ctx.logger |
import { createAIHelper } from "@repo/ai" | ctx.ai |
These rules ensure that database queries go through the RLS-scoped transaction, logs include request context, and AI calls are tracked per-tenant.