name: trpc description: Conventions for writing tRPC routers and procedures in PrairieLearn.
Quick reference
- Adding a procedure to an existing feature? Find the subrouter file in the appropriate scope directory, add the procedure. No other files need changes.
- Adding a new subrouter to an existing scope? Create a new file in the scope directory, export a router, register it in that scope's
trpc.ts. - Adding a new scope? Copy an existing scope directory (e.g.
trpc/assessment/), adjust theResLocalsForPagetype, context fields, URL helper inlib/client/url.ts, and mount path inserver.ts. - Wiring up client-side React? Follow the pattern in any page that already uses tRPC (e.g.
pages/instructorInstanceAdminSettings/). Key pieces:generatePrefixCsrfTokenfrom@prairielearn/signed-tokenserver-side, scope'sclient.ts+context.ts+QueryClientProviderDebugclient-side. - Returning typed errors? See Typed errors.
Authorization scopes
tRPC routers are mounted per authorization scope, not per page. Each scope has its own directory under apps/prairielearn/src/trpc/ and is mounted behind the corresponding Express authorization middleware chain.
| Scope | Directory | Mount path | ResLocalsForPage type |
|---|---|---|---|
| administrator | trpc/administrator/ |
/pl/administrator/trpc |
'plain' |
| course | (create when needed) | /pl/course/:course_id/trpc |
'course' |
| course instance | trpc/courseInstance/ |
/pl/course_instance/:id/instructor/trpc |
'course-instance' |
| assessment | trpc/assessment/ |
.../assessment/:assessment_id/trpc |
'assessment' |
| assessment question | trpc/assessmentQuestion/ |
.../assessment/:aid/assessment_question/:aqid/trpc |
'instructor-assessment-question' |
| assessment instance | (create when needed) | .../assessment_instance/:ai_id/trpc |
'assessment-instance' |
| instance question | (create when needed) | .../instance_question/:iq_id/trpc |
'instance-question' |
Do not create per-page tRPC routers.
File structure
Every scope directory contains: init.ts, trpc.ts, client.ts, context.ts, plus one *.ts file per subrouter and optional *.sql files for scope-specific queries. All routers use superjson as the transformer in both init.ts and client.ts.
See any existing scope (e.g. trpc/assessment/) for the exact boilerplate. The files follow a mechanical pattern — init.ts creates the tRPC instance and authorization middleware, trpc.ts composes subrouters and exports Express middleware via createExpressMiddleware, client.ts creates the HTTP client with CSRF headers, context.ts exports TRPCProvider/useTRPC via createTRPCContext.
Conventions
Naming
- Procedure names describe the action:
list,upsert,destroy— notlistWidgetsordeleteMutation. - Variable names may use a
Mutation/Proceduresuffix (e.g.const setModeMutation = t.procedure...), but the router key must not:setMode: setModeMutation. - Subrouter exports:
{feature}Router(e.g.widgetsRouter). Router keys: camelCase (widgets).
Authorization
- Permission middleware (
requireCourseInstancePermissionView, etc.) is defined ininit.tsand chained with.use(). - Feature flag middleware (checking
features.enabled(...)) goes in the subrouter file, notinit.ts. Seetrpc/assessment/access-control.tsfor an example. - Each scope's
createContextincludes the fulllocalsobject alongside extracted fields as an escape hatch.
Returning data
- Use role-scoped schemas from
lib/client/safe-db-types.ts(e.g.StaffStudentLabelSchema). Always.parse()records through the schema before returning. - Use existing model functions from
models/instead of one-off SQL.
Client-side queries and mutations
- Use
trpc.subrouter.procedure.queryOptions()withuseQuery()for queries. Do not manually construct{ queryKey, queryFn }objects — the generatedqueryOptions()provides type-safe keys and avoids stale cache issues. - Use
trpc.subrouter.procedure.mutationOptions()withuseMutation()for mutations. - Access the
trpcproxy via the scope'suseTRPC()hook fromcontext.ts.
Client-side CSRF flow
The CSRF token is generated server-side with generatePrefixCsrfToken using the scope's URL helper from lib/client/url.ts, passed as a prop to the hydrated component, and sent by the tRPC client as an X-CSRF-Token header. The Express CSRF middleware validates it before the request reaches the tRPC router.
Errors
- Default to plain
TRPCError. UsethrowAppErroronly when the client needs extra fields beyondmessage(e.g.jobSequenceIdto link to logs) or must branch on the code. A code that the client just renders asmessageis indistinguishable fromUNKNOWN— don't add it. - Every subrouter exports an error interface enumerating each procedure, with
neverfor procedures that have no typed errors:
Don't useexport interface AssessmentGroupsError { AddGroup: never; EnableGroupWork: { code: 'SYNC_JOB_FAILED'; jobSequenceId: string }; }Record<string, never>or emptyinterface XError {}. message: stringis added automatically bythrowAppErrorandgetAppError— only declare procedure-specific fields.- Client uses
getAppError<XError['Procedure']>(mutation.error)— procedure-keyed even when the entry isnever. Never read raw mutation/query errors. - Render with
<AppErrorAlert>orrenderAppError, not{appError.message}directly. The exhaustive renderer map makes it a compile error to forget a variant or silently drop fields likejobSequenceId:
Use<AppErrorAlert error={copyError} onDismiss={() => copyMutation.reset()} render={{ SYNC_JOB_FAILED: ({ message, jobSequenceId }) => ( <> {message} <a href={`${urlPrefix}/jobSequence/${jobSequenceId}`}>View job logs</a> </> ), UNKNOWN: ({ message }) => message, }} />renderAppError(error, {...})when you need aReactNodeto pass elsewhere (e.g.StickySaveBar'salert.message). - See
pages/instructorStudentsLabels/components/LabelModifyModal.tsx(typed) andpages/administratorCourses/administratorCourses.html.tsx(never-keyed) for examples.
Testing
tRPC procedures are tested as integration tests via HTTP. See tests/instructorStudentsLabels.test.ts for the pattern: create a client with generatePrefixCsrfToken + the scope's create*TrpcClient, call procedures directly, assert with TRPCClientError for error cases.