tRPC v11 API layer. The server export provides the appRouter (composed from domain routers: patients, patientSessions, profiles, orgMembers, facilities, templates, auditTemplates) and a fetch adapter. The client export provides TRPCProvider, useTRPC, useTRPCClient, and api for React Query–integrated procedure calls. It also exports authenticatedProcedure and Zod-based input schemas.
graph TD
trpc["@repo/trpc"]
auth["@repo/auth"]
db["@repo/db"]
legacy["@repo/legacy"]
logger["@repo/logger"]
safe["@repo/safe"]
react_query["@repo/react-query"]
typescript_config["@repo/typescript-config"]
vitest_config["@repo/vitest-config"]
services["@repo/services"]
utils["@repo/utils"]
trpc -.-> auth
trpc -.-> db
trpc -.-> legacy
trpc -.-> logger
trpc -.-> safe
trpc -.-> react_query
trpc -.-> typescript_config
trpc -.-> vitest_config
trpc -.-> services
trpc -.-> utilsEach procedure lives in its own file (*.query.ts or *.mutation.ts). Define a Zod input schema, export inferred types, and use authenticatedProcedure:
// src/routers/patients/get-patient-by-id.query.ts
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { patientsRepo } from "@repo/db";
import { zPatientId } from "@repo/db/schema";
import { authenticatedProcedure } from "../../procedures";
export type GetPatientByIdInput = z.infer<typeof getPatientByIdInputSchema>;
export type GetPatientByIdOutput = Awaited<ReturnType<typeof getPatientById>>;
export const getPatientByIdInputSchema = z.object({
id: zPatientId,
});
export const getPatientById = authenticatedProcedure
.input(getPatientByIdInputSchema)
.query(async ({ input }) => {
const { data, error } = await patientsRepo.getById(input.id);
if (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message: error.message || "Unknown error",
});
}
return data;
});
Mutations follow the same pattern but use .mutation(...). The ctx object provides the authenticated user:
// src/routers/patients/create-patient.mutation.ts
export const createPatientInputSchema = z.object({
firstName: z.string().min(1, "First name is required"),
lastName: z.string().min(1, "Last name is required"),
facilityId: zFacilityId.optional(),
email: z.email().optional().or(z.literal("")),
// ...
});
export type CreatePatientInput = z.infer<typeof createPatientInputSchema>;
export const createPatient = authenticatedProcedure
.input(createPatientInputSchema)
.mutation(async ({ ctx, input }) => {
const { data, error } = await patientsRepo.create({
first_name: input.firstName,
last_name: input.lastName,
entity_id: ctx.user.entity_id,
// ...
});
if (error) {
throw new TRPCError({ code: "BAD_REQUEST", message: error.message || "Unknown error" });
}
return data;
});
Each domain has an index.ts that composes individual procedures into a router. Types are re-exported with export type *:
// src/routers/patients/index.ts
import { trpc } from "../../trpc";
import { createPatient } from "./create-patient.mutation";
import { getPatientById } from "./get-patient-by-id.query";
import { listInOrganization } from "./list-in-organization.query";
import { updatePatient } from "./update-patient.mutation";
export type * from "./create-patient.mutation";
export type * from "./get-patient-by-id.query";
// ...
export const patientsRouter = trpc.router({
createPatient,
getPatientById,
listInOrganization,
updatePatient,
});
Domain routers are then merged into the appRouter:
// src/routers/app.router.ts
export const appRouter = trpc.router({
patients: patientsRouter,
patientSessions: patientSessionsRouter,
profiles: profilesRouter,
// ...
});
For access-control checks that load and validate a resource, define composable middleware that can be chained onto a procedure with .concat:
// src/middleware/auth/can-access-audit-template.middleware.ts
export function canAccessAuditTemplate({ getId }: CanAccessAuditTemplateOptions) {
return authenticatedProcedure.use(async (opts) => {
const id = getId(opts.input as Record<string, unknown>);
const { data: template, error } = await auditTemplatesRepo.getById(id);
if (error) throw new TRPCError({ code: "NOT_FOUND" });
if (!hasAccessToAuditTemplate(template, opts.ctx.user)) throw new TRPCError({ code: "FORBIDDEN" });
return opts.next({ ctx: { auditTemplate: template } });
});
}
Then use .concat to attach it after .input:
export const getById = authenticatedProcedure
.input(getAuditTemplateByIdInputSchema)
.concat(canAccessAuditTemplate({ getId: (input) => input.id as AuditTemplateId }))
.query(async ({ input, ctx }) => {
// ctx.auditTemplate is now available and typed
});
Use useTRPC() to get the typed proxy, then pass queryOptions into TanStack Query's useQuery:
import { useTRPC } from "@repo/trpc/client";
import { useQuery } from "@repo/react-query";
function PatientList({ search }: { search: string }) {
const trpc = useTRPC();
const { data, isLoading, error } = useQuery(
trpc.patients.listInOrganization.queryOptions({
sortBy: "name",
sortOrder: "asc",
limit: 25,
offset: 0,
patientSearch: search || undefined,
}),
);
}
Pass TanStack Query options like enabled as the second argument to queryOptions:
function SessionDetail({ sessionId }: { sessionId: SessionId }) {
const trpc = useTRPC();
const { data } = useQuery(
trpc.patientSessions.getById.queryOptions(
{ id: sessionId },
{ enabled: !!sessionId },
),
);
}
Use mutationOptions with useMutation. Invalidate related queries in onSuccess using queryKey():
import { useTRPC } from "@repo/trpc/client";
import { useMutation, useQueryClient } from "@repo/react-query";
function useCreateClient(options?: { onSuccess?: () => void }) {
const trpc = useTRPC();
const queryClient = useQueryClient();
return useMutation(
trpc.patients.createPatient.mutationOptions({
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: trpc.patients.listInOrganization.queryKey(),
});
options?.onSuccess?.();
},
}),
);
}
Use the fetch adapter to mount the router in a Next.js route handler:
import { createTRPCFetchAdapter } from "@repo/trpc/server";
const handler = createTRPCFetchAdapter({
endpoint: "/api/trpc",
createContext: ({ req }) => createContext(req),
});
export { handler as GET, handler as POST };
| Script | Description |
|---|---|
check-types |
Typecheck with tsc --noEmit |