tRPC Server Setup
How the tRPC server is initialized, context is created, and procedures build the middleware chain.
This page covers the server-side tRPC setup: how the tRPC instance is initialized, how request context is created, and how the four procedure levels are defined.
tRPC Initialization
The tRPC instance is created in packages/api/src/trpc.ts:
const t = initTRPC.context<Context>().create({
errorFormatter({ shape, error }) {
return {
...shape,
data: {
...shape.data,
zodError: error.cause instanceof ZodError ? z.treeifyError(error.cause) : null,
},
};
},
});
export const router = t.router;
The Context type comes from packages/api/src/context.ts. The error formatter adds structured Zod validation errors to every response -- see Error Handling for details.
Context Creation
Every tRPC request starts with createContext, which extracts the session from request headers and creates a structured logger:
export async function createContext(opts: { headers: Headers }) {
const session = await auth.api.getSession({
headers: opts.headers,
query: { disableCookieCache: true },
});
const logger = createRequestLogger(new Request("http://localhost", { headers: opts.headers }), {
userId: session?.user.id,
tenantId: session?.session.activeOrganizationId ?? undefined,
});
return { session, headers: opts.headers, logger };
}
Key details:
sessionmay benullfor unauthenticated requests (health checks, public endpoints)disableCookieCache: trueforces a fresh session lookup on every request- The logger is bound with
userIdandtenantIdfrom the session, so every downstream log entry includes these fields automatically
The Four Procedure Levels
Each procedure level extends the one above it, adding more guarantees to the context.
publicProcedure
Adds request logging (path, type, duration). No authentication required. Used only for the health check endpoint.
export const publicProcedure = t.procedure.use(async ({ ctx, path, type, next }) => {
const start = performance.now();
const result = await next();
const durationMs = Math.round(performance.now() - start);
if (result.ok) {
ctx.logger.info({ path, type, durationMs }, "tRPC request completed");
} else {
ctx.logger.error(
{ path, type, durationMs, error: result.error.message },
"tRPC request failed",
);
}
return result;
});
protectedProcedure
Guarantees ctx.session is non-null. Throws UNAUTHORIZED if no session exists. Used for user-scoped operations that do not need tenant context (PAT management).
export const protectedProcedure = t.procedure.use(({ ctx, next }) => {
if (!ctx.session) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
return next({ ctx: { session: ctx.session } });
});
tenantProcedure
Extends protectedProcedure. Extracts the active organization from the session and wraps downstream execution in withTenantContext, which opens an RLS-scoped database transaction. After this middleware, ctx.db is a tenant-isolated transaction.
Rarely used directly -- prefer authorizedProcedure which adds CASL on top.
authorizedProcedure
Extends tenantProcedure. Looks up the user's member record, builds a CASL ability based on their role and org type, and creates a pre-bound AI helper. This is the procedure all feature routers use.
After authorizedProcedure, the context provides:
| Property | Type | Source |
|---|---|---|
ctx.session | Session | protectedProcedure |
ctx.organizationId | string | tenantProcedure |
ctx.db | Transaction | tenantProcedure (RLS-scoped) |
ctx.member | Member | authorizedProcedure |
ctx.ability | AppAbility | authorizedProcedure |
ctx.ai | AIHelper | authorizedProcedure |
ctx.logger | Logger | createContext |
For a detailed walkthrough of how each middleware layer builds the context, see Data & Storage -- Procedure Chain.
The Root Router
The appRouter in packages/api/src/router.ts merges all feature routers into a single router:
export const appRouter = router({
health: publicProcedure.query(() => ({ status: "ok" })),
aiLogs: aiLogsRouter,
hybridSearch: hybridSearchRouter,
organization: organizationRouter,
member: memberRouter,
pat: patRouter,
researchArtifact: researchArtifactRouter,
researchPlan: researchPlanRouter,
skillExecution: skillExecutionRouter,
});
export type AppRouter = typeof appRouter;
The AppRouter type is exported and consumed by the client-side tRPC setup to provide end-to-end type safety. See Client Configuration.
The Next.js Route Handler
The appRouter is served via a Next.js catch-all API route at apps/web/src/app/api/trpc/[trpc]/route.ts:
function handler(req: Request) {
return fetchRequestHandler({
endpoint: "/api/trpc",
req,
router: appRouter,
createContext: ({ req }) => createContext({ headers: req.headers }),
onError({ error, path, type }) {
if (CLIENT_ERROR_CODES.has(error.code)) return;
Sentry.withScope((scope) => {
scope.setTag("trpc.path", path);
scope.setTag("trpc.type", type);
scope.setTag("trpc.code", error.code);
Sentry.captureException(error);
});
},
});
}
const GET = handler;
const POST = handler;
export { GET, POST };
The handler uses tRPC's fetchRequestHandler adapter. Both GET and POST are handled by the same function. The onError callback reports unexpected errors to Sentry while filtering out expected client errors (UNAUTHORIZED, FORBIDDEN, NOT_FOUND, BAD_REQUEST, PARSE_ERROR, PRECONDITION_FAILED). See Error Handling for details.
Package Exports
The @repo/api package exports everything consumers need from packages/api/src/index.ts:
export { type Actions, defineAbilityFor, type OrgType, type Subjects } from "./abilities";
export { type Context, createContext } from "./context";
export { type AppRouter, appRouter } from "./router";
export {
type AppAbility,
authorizedProcedure,
protectedProcedure,
publicProcedure,
router,
tenantProcedure,
} from "./trpc";
The primary consumers are:
apps/web-- importsappRouter,createContext, andAppRoutertype for the route handler and client setup- Test files -- import
appRouterforcreateCaller-based integration tests