The API layer uses tRPC v11 integrated with TanStack React Query. All tRPC code lives in packages/trpc.
subgraph server [Server - packages/trpc] Adapter["Fetch Adapter<br/>(/api/trpc)"] Router["App Router"] Procedures["Procedures<br/>(public, authenticated)"] Middleware["Middleware<br/>(access checks)"] end
subgraph data [Data - packages/db] Repos["Repositories"] end
Hooks -->|HTTP batch| Adapter Adapter --> Router Router --> Procedures Procedures --> Middleware Middleware --> Repos
subgraph server [Server - packages/trpc] Adapter["Fetch Adapter<br/>(/api/trpc)"] Router["App Router"] Procedures["Procedures<br/>(public, authenticated)"] Middleware["Middleware<br/>(access checks)"] end
subgraph data [Data - packages/db] Repos["Repositories"] end
Hooks -->|HTTP batch| Adapter Adapter --> Router Router --> Procedures Procedures --> Middleware Middleware --> Repos
graph LR
subgraph client [Client - apps/web]
Hooks["useTRPC() + useQuery/useMutation"]
end
subgraph server [Server - packages/trpc]
Adapter["Fetch Adapter<br/>(/api/trpc)"]
Router["App Router"]
Procedures["Procedures<br/>(public, authenticated)"]
Middleware["Middleware<br/>(access checks)"]
end
subgraph data [Data - packages/db]
Repos["Repositories"]
end
Hooks -->|HTTP batch| Adapter
Adapter --> Router
Router --> Procedures
Procedures --> Middleware
Middleware --> Repos
The app router composes domain-specific routers:
// packages/trpc/src/routers/app.router.ts
export const appRouter = trpc.router({
patients: patientsRouter,
patientSessions: patientSessionsRouter,
profiles: profilesRouter,
orgMembers: orgMembersRouter,
facilities: facilitiesRouter,
templates: templatesRouter,
auditTemplates: auditTemplatesRouter,
});
Each domain router lives in its own folder under packages/trpc/src/routers/:
packages/trpc/src/routers/
├── app.router.ts # Root router composition
├── patients/
│ ├── index.ts # Composes the patients router
│ ├── list.query.ts
│ ├── get-by-id.query.ts
│ ├── create.mutation.ts
│ └── update.mutation.ts
├── audit-templates/
│ ├── index.ts
│ ├── get-by-id.query.ts
│ ├── create.mutation.ts
│ ├── update.mutation.ts
│ └── delete.mutation.ts
└── ...
Each operation is a separate file named <operation>.<query|mutation>.ts:
list.query.ts -- list resourcesget-by-id.query.ts -- fetch a single resourcecreate.mutation.ts -- create a resourceupdate.mutation.ts -- update a resourcedelete.mutation.ts -- delete a resourceProcedures define the authentication/authorization layers. They are defined in packages/trpc/src/procedures.ts:
// No auth required
export const publicProcedure = trpc.procedure;
// Requires a logged-in user (throws UNAUTHORIZED otherwise)
export const authenticatedProcedure = trpc.procedure.use(async function isAuthenticated({ ctx, next }) {
if (!ctx.user) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
return next({ ctx: { ...ctx, user: ctx.user } });
});
// Requires a specific role (extends authenticatedProcedure)
export const authorizedProcedure = (allowedRoles: unknown[]) => {
return authenticatedProcedure.use(async function hasAllowedRole({ ctx, next }) {
// Role checking (TODO: re-implement)
return next({ ctx: { ...ctx, user: ctx.user } });
});
};
Most procedures use authenticatedProcedure as their base.
The tRPC context is intentionally minimal:
// packages/trpc/src/context.ts
export type TRPCContext = {
user: User | null;
};
export type AuthenticatedTRPCContext = SetNonNullable<TRPCContext, "user">;
The database is not in the context. Repos are imported directly where needed. The context is created per-request in the web app:
// apps/web/lib/trpc.context.ts
export async function createTRPCContext() {
const user = await getLoggedInUser();
return { user };
}
For resource-level access checks, we use a middleware pattern that loads the resource and verifies access before the procedure runs:
// packages/trpc/src/middleware/auth/can-access-audit-template.middleware.ts
export function canAccessAuditTemplate({ getId }: CanAccessAuditTemplateOptions) {
return authenticatedProcedure.use(async (opts) => {
const input = opts.input as Record<string, unknown>;
const id = getId(input);
const { data: template, error } = await auditTemplatesRepo.getById(id);
if (error) {
throw new TRPCError({ code: "NOT_FOUND", message: "Audit template not found" });
}
if (!hasAccessToAuditTemplate(template, opts.ctx.user)) {
throw new TRPCError({ code: "FORBIDDEN" });
}
return opts.next({ ctx: { auditTemplate: template } });
});
}
This middleware is composed into procedures using .concat():
// packages/trpc/src/routers/audit-templates/get-by-id.query.ts
export const getById = authenticatedProcedure
.input(getAuditTemplateByIdInputSchema)
.concat(canAccessAuditTemplate({ getId: (input) => input.id as AuditTemplateId }))
.query(async ({ input, ctx }) => {
// ctx.auditTemplate is now available (added by middleware)
const { data: questions, error } = await auditQuestionsRepo.listByTemplateId(input.id);
if (error) {
throw new TRPCError({ code: "BAD_REQUEST", message: questionsError.message || "Unknown error" });
}
return { questions, auditTemplate: ctx.auditTemplate };
});
Inputs use Zod schemas with branded ID validators from @repo/db/schema:
import { zAuditTemplateId } from "@repo/db/schema";
export const getAuditTemplateByIdInputSchema = z.object({
id: zAuditTemplateId,
});
export type GetAuditTemplateByIdInput = z.infer<typeof getAuditTemplateByIdInputSchema>;
Export input/output types so consumers (tests, other packages) can use them.
The tRPC client is provided via TRPCProvider in apps/web/lib/providers.tsx. It uses an HTTP batch link pointing to /api/trpc.
The preferred pattern is useTRPC() + .queryOptions() with useQuery:
import { useQuery } from "@repo/trpc/client";
function PatientList() {
const trpc = useTRPC();
const { data, isLoading } = useQuery(
trpc.patients.list.queryOptions({ entityId })
);
// ...
}
import { useMutation, useTRPC, useTRPCClient } from "@repo/trpc/client";
function CreatePatient() {
const trpc = useTRPC();
const queryClient = useQueryClient();
const mutation = useMutation(
trpc.patients.create.mutationOptions({
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: trpc.patients.list.queryKey() });
},
})
);
// mutation.mutate({ ... });
}
@repo/trpc/client re-exports everything you need:
| Export | Source | Purpose |
|---|---|---|
useTRPC |
tRPC React Query | Access procedure options |
TRPCProvider |
tRPC React Query | Provider component |
useQuery, useMutation, useInfiniteQuery |
@repo/react-query |
React Query hooks |
useTRPCClient |
tRPC React Query | Direct client access |
The fetch adapter at apps/web/app/api/trpc/[trpc]/route.ts connects the Next.js route handler to the tRPC router:
import { createTRPCFetchAdapter } from "@repo/trpc/server";
import { createTRPCContext } from "~/lib/trpc.context";
const handler = createTRPCFetchAdapter({ createContext: createTRPCContext });
export { handler as GET, handler as POST };
packages/trpc/src/routers/patients/archive.mutation.ts).authenticatedProcedure.input(schema).mutation(...).index.ts.trpc.patients.archive.Next: Error Handling