Builds end-to-end type-safe tRPC APIs: routers, Zod procedures, dual Next.js App Router context factories, protected middleware, and subscriptions. Use when scaffolding tRPC in a TypeScript monorepo or migrating REST/GraphQL incrementally inside one repo. Not for OpenAPI/REST-only backends or GraphQL schema-first stacks. Do not share one context factory between fetch Request handlers and RSC callers.
Builds end-to-end type-safe tRPC APIs: routers, Zod procedures, dual Next.js App Router context factories, protected middleware, and subscriptions. Use when scaffolding tRPC in a TypeScript monorepo or migrating REST/GraphQL incrementally inside one repo. Not for OpenAPI/REST-only backends or GraphQL schema-first stacks. Do not share one context factory between fetch Request handlers and RSC callers.
tRPC lets you build fully type-safe APIs without writing a schema or code-generation step. TypeScript types flow from the server router directly to the client — every API call is autocompleted, validated at compile time, and refactoring-safe. Use this skill when building TypeScript monorepos, Next.js apps, or any project where the server and client share a codebase.
When to Use
Building a TypeScript full-stack app (Next.js App Router, Remix, Express + React) where client and server share a single repo
You want end-to-end type safety on API calls without REST/GraphQL schema overhead
Adding real-time features (subscriptions) to an existing tRPC setup
HARD RULE: Next.js App Router handlers receive a fetch Request, not a Node.js NextApiRequest. You must define separate context factories — one for the HTTP handler, one for direct server-side callers (Server Components, RSC, cron jobs).
// src/server/context.tsimport { typeFetchCreateContextFnOptions } from'@trpc/server/adapters/fetch';
import { auth } from'@/server/auth'; // Next-Auth v5 / your auth helperimport { db } from'./db';
/**
* Context for the HTTP handler (App Router Route Handler).
* opts.req is the fetch Request — auth is resolved server-side via auth().
*/exportasyncfunctioncreateTRPCContext(opts: FetchCreateContextFnOptions) {
const session = awaitauth();
return { session, db, headers: opts.req.headers };
}
/**
* Context for direct server-side callers (Server Components, RSC, cron jobs).
* No HTTP request is involved — call auth() directly from the server.
*/exportasyncfunctioncreateServerContext() {
const session = awaitauth();
return { session, db };
}
exporttypeContext = Awaited<ReturnType<typeof createTRPCContext>>;
Step 3: Build an Auth Middleware and Protected Procedure
// src/server/trpc.ts (continued)const enforceAuth = middleware(({ ctx, next }) => {
if (!ctx.session?.user) {
thrownewTRPCError({ code: 'UNAUTHORIZED' });
}
returnnext({
ctx: {
// Narrows type: session is non-null from here downstreamsession: { ...ctx.session, user: ctx.session.user },
},
});
});
exportconst protectedProcedure = t.procedure.use(enforceAuth);
Step 4: Create Domain Routers
Split routers by domain (posts, users, billing) and merge them in root.ts.
// src/server/root.tsimport { router } from'./trpc';
import { postRouter } from'./routers/post';
import { userRouter } from'./routers/user';
exportconst appRouter = router({
post: postRouter,
user: userRouter,
});
// Export the TYPE for the client — never import appRouter itself on the clientexporttypeAppRouter = typeof appRouter;
Step 6: Mount the API Handler (Next.js App Router)
HARD RULE: The App Router handler must use fetchRequestHandler from @trpc/server/adapters/fetch and the fetch-based context factory. Do NOT use @trpc/server/adapters/next — that adapter is for Pages Router only.
Auth session is null in protected procedures even when the user is logged in. Ensure createTRPCContext uses the correct server-side auth call (e.g. auth() from Next-Auth v5) and is not receiving a Pages Router req/res cast via as any in an App Router handler.
Server Component caller fails for auth-dependent queries. Use createServerContext() (the dedicated server-side factory) instead of passing an empty or synthetic object to createContext. Never use createContext({} as any).
"Type error: AppRouter is not assignable to AnyRouter". Import AppRouter as a type import (import type { AppRouter }) on the client, not the full module.
Mutations not reflecting in the UI after success. Call utils.<router>.<procedure>.invalidate() in onSuccess to trigger a refetch via React Query.
"Cannot find module '@trpc/server/adapters/next'" with App Router. Use @trpc/server/adapters/fetch and fetchRequestHandler for the App Router. The nextjs adapter is for Pages Router only.
Subscriptions not connecting. Subscriptions require splitLink — route subscriptions to wsLink and queries/mutations to httpBatchLink. Without splitLink, the client will attempt HTTP for subscription calls and fail silently.
Don't cast context with as any to silence type errors — the mismatch will surface as a runtime failure when auth or session lookups return undefined.
Don't share the tRPC client instance globally — create it per-provider to avoid stale closures and stale auth headers.
Don't put business logic in the route handler — keep it in the procedure or a service layer.
Always validate all input shapes with Zod, including pagination cursors and IDs, to prevent injection via malformed inputs.
Always enforce authorization in protectedProcedure — never rely on client-side checks alone.
Avoid exposing internal error details to clients — use TRPCError with a public-safe message and keep stack traces server-side only.
Verification
Type-check the server and client share the same router type:
npx tsc --noEmit
Expected: no errors. If you see "AppRouter is not assignable to AnyRouter," switch the client import to import type { AppRouter }.
Verify the API handler responds:
curl -X POST http://localhost:3000/api/trpc/post.list -H "content-type: application/json" -d '{"json":{"limit":5}}'
Expected: a JSON response with a result object containing posts and nextCursor.
curl -X POST http://localhost:3000/api/trpc/post.create -H "content-type: application/json" -d '{"json":{"title":"test","body":"test"}}'
Expected: an error response with code: 'UNAUTHORIZED'.
Verify the client type-safety in the editor: In a client component, type trpc.post. and confirm autocomplete shows list, byId, create, delete. If autocomplete is empty, the AppRouter type import is missing or wrong.
Verify server-side caller works in a Server Component: Navigate to the /posts route and confirm posts render without a UNAUTHORIZED or context error.
Related Skills
typescript-expert — Deep TypeScript patterns used inside tRPC routers and generic utilities
react-patterns — React hooks patterns that pair with trpc.*.useQuery and useMutation
test-driven-development — Write procedure unit tests using createCallerFactory without an HTTP server
security-auditor — Review tRPC middleware chains for auth bypass and input validation gaps