tRPC v11 End-to-End Type Safety: Server + Client (2026)
June 22, 2026
tRPC gives a TypeScript client full type inference over server procedures with no code generation and no separate API schema. This tutorial builds a complete, runnable tRPC v11 API — a standalone Node server and a vanilla client — without Next.js or any framework.
TL;DR
You will build a small "notes" API from an empty folder using tRPC v11.18.0. You define a router with two queries and a mutation, validate input with Zod, serve it with the standalone adapter (a thin wrapper over Node's http server), then call it from a fully typed vanilla client where the editor knows every input and return type. You will add TRPCError handling, inspect the raw HTTP wire format with curl, and fix a very common setup bug (a basePath/url mismatch). Every command and code block was verified end to end — tsc --noEmit, server, client, and curl — on 22 June 2026.
What you'll learn
- How to scaffold a Node + TypeScript project that runs
.tsfiles directly - How to define a tRPC router with queries and a mutation
- How to validate procedure input with Zod
- How to serve the router with the standalone adapter and enable CORS
- How to build a vanilla client whose types come straight from the server
- How to throw and handle typed errors with
TRPCError - How to verify the API over raw HTTP with
curl
Prerequisites
- Node 20 or newer — any maintained LTS works. Verified here on Node 22.22.3. (tRPC v11 does not declare a Node engine, but the standalone adapter wraps Node's built-in HTTP server, so a current LTS is the safe baseline.)
- TypeScript 5.7.2 or newer — this is tRPC v11's peer requirement.1 We pin 6.0.3.
- Basic familiarity with
async/awaitand the terminal.
A quick note on versions: tRPC v11 shipped as stable on 21 March 20252 after a long run on the @next tag, so the API in this guide is settled, not bleeding-edge.
Step 1 — Scaffold the project
Create a folder, initialize an ESM package, and install the pinned dependencies.
mkdir trpc-notes && cd trpc-notes
npm init -y
npm pkg set type=module
# runtime deps
npm install @trpc/server@11.18.0 @trpc/client@11.18.0 zod@4.4.3 cors@2.8.6
# dev deps: TypeScript, a .ts runner, and types
npm install -D typescript@6.0.3 tsx@4.22.4 @types/node@26.0.0 @types/cors@2.8.19
tsx lets us run TypeScript files directly (no separate build step during development), and cors is needed only because the standalone server does not set CORS headers on its own.3
Create a tsconfig.json tuned for type-checking (not emitting — tsx handles execution):
{
"compilerOptions": {
"target": "es2022",
"module": "preserve",
"moduleResolution": "bundler",
"strict": true,
"verbatimModuleSyntax": true,
"skipLibCheck": true,
"noEmit": true
},
"include": ["src"]
}
verbatimModuleSyntax forces you to use import type for type-only imports — which matters a lot in a moment, because importing the type of your router is the whole trick that keeps server code out of your client bundle.
Step 2 — Define a tRPC router with queries and a mutation
This is the heart of tRPC. Create src/router.ts. You initialize tRPC once, export reusable helpers, then declare procedures. A .query() is for reads, a .mutation() is for writes.
// src/router.ts
import { initTRPC, TRPCError } from '@trpc/server';
import { z } from 'zod';
const t = initTRPC.create();
export const router = t.router;
export const publicProcedure = t.procedure;
interface Note {
id: string;
title: string;
body: string;
createdAt: string;
}
// In a real app this would be your database. Keep it in memory for the demo.
const notes: Note[] = [
{ id: '1', title: 'Welcome', body: 'Your first note.', createdAt: new Date().toISOString() },
];
export const appRouter = router({
// a query with no input
list: publicProcedure.query(() => notes),
// a query with a validated string input
byId: publicProcedure.input(z.string()).query(({ input }) => {
const note = notes.find((n) => n.id === input);
if (!note) {
throw new TRPCError({ code: 'NOT_FOUND', message: `No note with id ${input}` });
}
return note;
}),
// a mutation with a validated object input
create: publicProcedure
.input(z.object({ title: z.string().min(1), body: z.string().min(1) }))
.mutation(({ input }) => {
const note: Note = {
id: String(notes.length + 1),
title: input.title,
body: input.body,
createdAt: new Date().toISOString(),
};
notes.push(note);
return note;
}),
});
// Export ONLY the type. This is what the client imports.
export type AppRouter = typeof appRouter;
The last line is the one that earns tRPC its tagline. AppRouter is a pure TypeScript type describing every procedure, its input, and its return value. The client imports this type and nothing else — no runtime server code crosses the boundary.
Step 3 — Validate input with Zod
You already did this in Step 2, and it is worth slowing down on because it is doing double duty. Each .input() call takes a validator. At runtime, tRPC parses the incoming request against the schema and rejects anything that does not match before your handler ever runs. At compile time, the schema's inferred type becomes the type of input inside the handler — and the type the client is forced to pass.
So z.object({ title: z.string().min(1), body: z.string().min(1) }) means a client call to create with a missing title, or a number where a string belongs, is a TypeScript error in your editor and a validation error at runtime. tRPC v11 accepts any validator that implements the Standard Schema spec, so Zod, Valibot, and ArkType all work the same way.4 We use Zod 4 here.
Step 4 — Serve it with the standalone adapter
The standalone adapter is the simplest way to run a tRPC router: it is a thin wrapper around Node's built-in HTTP server.3 Create src/server.ts:
// src/server.ts
import { createHTTPServer } from '@trpc/server/adapters/standalone';
import cors from 'cors';
import { appRouter } from './router';
const server = createHTTPServer({
router: appRouter,
middleware: cors(), // allow browser clients during development
basePath: '/trpc/', // requests are served under /trpc/*
createContext() {
return {};
},
});
server.listen(3000);
console.log('tRPC server on http://localhost:3000/trpc');
Two details that trip people up. First, cors() is passed as middleware because the standalone server sets no CORS headers by default — without it, browser clients fail with a CORS error (server-to-server calls and curl are unaffected).3 Second, basePath: '/trpc/' means every procedure lives under /trpc/ — list becomes http://localhost:3000/trpc/list. The default basePath is '/'; we set it explicitly so it matches the client URL in the next step. Remember this — it is the bug in the Troubleshooting section.
Start it in one terminal:
npx tsx src/server.ts
# tRPC server on http://localhost:3000/trpc
Step 5 — Build a vanilla client whose types come from the server
Create src/client.ts. The client imports the AppRouter type (note import type) and passes it to createTRPCClient. That single generic is what makes the whole client type-safe.
// src/client.ts
import { createTRPCClient, httpBatchLink } from '@trpc/client';
import type { AppRouter } from './router';
const trpc = createTRPCClient<AppRouter>({
links: [httpBatchLink({ url: 'http://localhost:3000/trpc' })],
});
async function main() {
// `query` for reads, `mutate` for writes — both fully typed
console.log('list ->', await trpc.list.query());
const created = await trpc.create.mutate({ title: 'Buy milk', body: '2 liters' });
console.log('create ->', created);
console.log('byId ->', await trpc.byId.query(created.id));
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
createTRPCClient is the v11 name. If you are following an older tutorial that uses createTRPCProxyClient, that function still exists but is deprecated — v11 dropped "Proxy" from the public client API.1 The httpBatchLink terminating link batches multiple simultaneous calls into one HTTP request automatically.5
Run it in a second terminal (leave the server running):
npx tsx src/client.ts
You'll see the exact output below — byId returns the note you just created, because the in-memory store persists for the life of the server process:
list -> [
{ id: '1', title: 'Welcome', body: 'Your first note.', createdAt: '2026-06-22T03:15:07.507Z' }
]
create -> { id: '2', title: 'Buy milk', body: '2 liters', createdAt: '2026-06-22T03:15:09.537Z' }
byId -> { id: '2', title: 'Buy milk', body: '2 liters', createdAt: '2026-06-22T03:15:09.537Z' }
The payoff: hover over created in your editor and it is typed { id: string; title: string; body: string; createdAt: string } — inferred straight from the server handler, with no shared interface, no OpenAPI document, and no codegen step. Change the server's return shape and the client stops compiling immediately. That is what "end-to-end type safety" means in practice.
Step 6 — Handle errors with TRPCError
You already threw a TRPCError in byId. tRPC maps its error code to an HTTP status automatically, so a NOT_FOUND becomes a 404 without you touching status codes.6 Add this to the client's main() to see it surface as a real rejection:
try {
await trpc.byId.query('999'); // no such note
} catch (err) {
// err is a TRPCClientError; err.data.code is 'NOT_FOUND', err.data.httpStatus is 404
console.error('expected error ->', (err as Error).message);
}
On the client, the thrown value is a TRPCClientError carrying the server's message plus a data object with code and httpStatus. Use the documented error codes (BAD_REQUEST, UNAUTHORIZED, FORBIDDEN, NOT_FOUND, CONFLICT, INTERNAL_SERVER_ERROR, and others) rather than inventing your own — they each map to a defined HTTP status.6 One production note: in development the error payload includes a full stack trace; tRPC omits the stack automatically when NODE_ENV is production.6
Verification — confirm it works over raw HTTP
tRPC is "just HTTP," and you can prove it without the client at all. Restart the server first so the in-memory store holds only the seed note, then hit it with curl. Queries are sent as GET and mutations as POST; httpBatchLink wraps everything in a batch envelope, so inputs are keyed by index ("0") and responses come back as an array.
# a query (GET): list all notes
curl 'http://localhost:3000/trpc/list?batch=1&input=%7B%7D'
# [{"result":{"data":[{"id":"1","title":"Welcome", ... }]}}]
# a query with input (GET): byId('1') — input is {"0":"1"} url-encoded
curl 'http://localhost:3000/trpc/byId?batch=1&input=%7B%220%22%3A%221%22%7D'
# [{"result":{"data":{"id":"1","title":"Welcome", ... }}}]
# a mutation (POST): create — body is {"0":{...}}; on a fresh server this is the 2nd note
curl -X POST 'http://localhost:3000/trpc/create?batch=1' \
-H 'content-type: application/json' \
-d '{"0":{"title":"From curl","body":"hello"}}'
# [{"result":{"data":{"id":"2","title":"From curl", ... }}}]
A missing note returns the typed error envelope, with the HTTP status set to 404:
curl 'http://localhost:3000/trpc/byId?batch=1&input=%7B%220%22%3A%22999%22%7D'
# [{"error":{"message":"No note with id 999","code":-32004,
# "data":{"code":"NOT_FOUND","httpStatus":404,"path":"byId", ... }}}]
The -32004 is the JSON-RPC error code tRPC uses for NOT_FOUND; the human-readable data.code and data.httpStatus are what you actually program against.
Finally, type-check the whole project to confirm nothing is loosely typed:
npx tsc --noEmit
# (no output = success)
Troubleshooting
- Every client call returns a 404, or
Unable to transform response from server. Your serverbasePathand clienturldisagree. If the server setsbasePath: '/trpc/', the clienturlmust behttp://localhost:3000/trpc(not the bare host). This mismatch is a very common first-run bug because the official standalone-server example defaultsbasePathto'/'while the client example points at/trpc— pick one and keep them in sync.37 - Browser client fails with a CORS error, but
curlworks. You forgotmiddleware: cors()on the standalone server.curland server-to-server calls ignore CORS; browsers do not.3 createTRPCProxyClient is deprecatedwarning. Rename it tocreateTRPCClient— same options, new name in v11.1tscerror: peer dependency / unsupported TypeScript. tRPC v11 requires TypeScript ≥ 5.7.2.1 Checknpx tsc --version.The transformer property has moved. In v11 a data transformer (like superjson) is configured on the link (httpBatchLink({ url, transformer })), not at the client root as in v10.1
Next steps
You now have a fully type-safe API with a server, a client, validation, and error handling — and you have seen that it is plain HTTP underneath. From here:
- Swap the in-memory array for a real database. The Drizzle ORM + pg-boss Atomic Transactions Tutorial shows a typed data layer your mutations can call.
- Lean harder on schema-first validation patterns, the same way the TanStack Router Type-Safe Search Params With Zod guide pushes Zod to the edges of the app.
- Speed up type-checking on a growing router with the native compiler covered in TypeScript 7 (tsgo): 10x Faster Compiler.
For production, add an authenticated createContext, swap the standalone adapter for the Express, Fastify, or Fetch adapter that matches your host, and consider the TanStack React Query integration when you wire this into a frontend.
Footnotes
-
tRPC docs, "Migrate from v10 to v11" —
createTRPCProxyClient→createTRPCClient, transformer-on-link, TypeScript ≥ 5.7.2. https://trpc.io/docs/migrate-from-v10-to-v11 ↩ ↩2 ↩3 ↩4 ↩5 -
tRPC, "Announcing tRPC v11" (21 March 2025). https://trpc.io/blog/announcing-trpc-v11 ↩
-
tRPC docs, "Standalone Adapter" (v11), including the
createHTTPServer,basePath, and CORS/middlewarebehavior. https://trpc.io/docs/server/adapters/standalone ↩ ↩2 ↩3 ↩4 ↩5 -
tRPC docs, "Input & Output Validators" — Standard Schema support (Zod, Valibot, ArkType). https://trpc.io/docs/server/validators ↩
-
tRPC docs, "HTTP Batch Link" — batching multiple calls into one request. https://trpc.io/docs/client/links/httpBatchLink ↩
-
tRPC docs, "Error Handling" —
TRPCErrorcodes and their HTTP status mappings. https://trpc.io/docs/server/error-handling ↩ ↩2 ↩3 -
tRPC docs, "Set up a tRPC Client" (v11) —
createTRPCClient,httpBatchLink, andclient.x.query()/.mutate()usage. https://trpc.io/docs/client/vanilla/setup ↩