trpc
SkillDev toolsConventions for writing tRPC routers and procedures.
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the trpc skill
What this skill tells your AI
The instructions your AI receives, as published by prairielearn/prairielearn in .agents/skills/trpc/SKILL.md and read by ahel’s review.
Quick reference
- Adding a procedure to an existing feature? The subrouter file in the appropriate scope directory is the primary location. Depending on the procedure, also update focused tests, SQL or model functions, safe-output schemas, and the subrouter's typed-error interface.
- 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, the scope'sclient.tsandcontext.ts, andQueryClientProviderDebugfrom@prairielearn/trpc/reactclient-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.
Shared infrastructure comes from explicit @prairielearn/trpc subpaths:
@prairielearn/trpc/serverforappErrorFormatterandthrowAppError.@prairielearn/trpc/clientforAppError,getAppError, andrenderAppError.@prairielearn/trpc/reactforAppErrorAlertandQueryClientProviderDebug.@prairielearn/trpc/expressfor tRPC request detection, multipart detection, and pre-adapter error responses.
Do not create a global tRPC initialization factory. Each scope initializes its context-bound t in its own init.ts with the shared appErrorFormatter.
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. ImportthrowAppErrorfrom@prairielearn/trpc/serverand use it only 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)from@prairielearn/trpc/client— procedure-keyed even when the entry isnever. Never read raw mutation/query errors. - Render with
<AppErrorAlert>from@prairielearn/trpc/reactorrenderAppErrorfrom@prairielearn/trpc/client, 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.
Signals
- GitHub stars
- 500
- Forks
- 391
- Last commit
- Sep 2026
Advanced
- Catalog kind
- skill
- Gateway key
trpc-prairielearn- Source
- github.com/prairielearn/prairielearn