The tRPC API
Overview
The API is tRPC v11 with TanStack Query v5 on the client. Routers live
in src/server/routers/ (one file per domain), merged in src/server/root.ts.
Server code here is never imported by client components.
Procedure tiers
Always use the most restrictive tier that fits — the context it injects is guaranteed, so you never re-check it:
| Tier | Use for | Injects |
|---|---|---|
publicProcedure | Unauthenticated endpoints | — |
protectedProcedure | Any authenticated action | ctx.user, ctx.session |
protectedAdminProcedure | Platform-admin operations | + role === "admin" |
protectedOrganizationProcedure | Org-scoped data | + ctx.organization, ctx.membership |
protectedSharedOrganizationProcedure | Shared-org (non-personal) data | + shared-org context |
Every input is validated
There are no exceptions — even a zero-input query declares a Zod schema. No raw input ever reaches a database query or external call:
getItem: protectedOrganizationProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
return ctx.db.item.findFirst({
where: { id: input.id, organizationId: ctx.organization.id },
})
})
Note the organizationId filter — mandatory on every org-scoped query.
Error handling
Throw TRPCError with a translation key as the message, never a raw English
string. Catch external/Prisma errors at the router boundary, log them, and
rethrow a generic error:
try {
// …Prisma work…
} catch (err) {
logger.error({ err, organizationId }, "items.create: failed")
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "errors.unexpected" })
}
The client receives the key and renders the translated string. Logging and
Sentry reporting are handled by middleware in procedures.ts — don't add
ad-hoc logging inside routers.
Calling the API
Client components use the generated hooks:
const { data } = api.items.list.useQuery({ ... })
const mutation = api.items.create.useMutation({
onSuccess: () => utils.items.list.invalidate(),
})
Server Components use the server-side caller to prefetch — don't fetch
your own endpoints:
const caller = await createCaller()
const items = await caller.items.list({ ... })
Never mix useEffect + fetch with tRPC for the same data — pick one.
Adding a router
- Create
src/server/routers/[domain].router.ts, export it as default. - Co-locate its Zod schemas in
[domain].schema.ts(reuse them for form validation on the client). - Merge it in
src/server/root.ts.