---
title: 'tRPC v11 End-to-End Type Safety: Server + Client (2026)'
description: 'Build a fully type-safe tRPC v11 API in TypeScript: a standalone server, a vanilla client, Zod validation, and error handling. No framework, all runnable.'
url: https://nerdleveltech.com/trpc-v11-end-to-end-type-safety-server-client-tutorial
language: en
date: '2026-06-22'
category: backend
tags:
  - trpc
  - trpc v11
  - typescript
  - type safety
  - zod
  - node
  - api
  - backend
markdown_url: https://nerdleveltech.com/trpc-v11-end-to-end-type-safety-server-client-tutorial.md
attribution: 'Source: NerdLevelTech — https://nerdleveltech.com/trpc-v11-end-to-end-type-safety-server-client-tutorial'
terms: https://nerdleveltech.com/llms.txt
---

# tRPC v11 End-to-End Type Safety: Server + Client (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 `.ts` files 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.[^4] We pin 6.0.3.
- Basic familiarity with `async/await` and the terminal.

A quick note on versions: tRPC v11 shipped as stable on **21 March 2025**[^1] 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.

```bash
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.[^2]

Create a `tsconfig.json` tuned for type-checking (not emitting — `tsx` handles execution):

```json
{
  "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.

```ts
// 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.[^5] 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.[^2] Create `src/server.ts`:

```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).[^2] 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:

```bash
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.

```ts
// 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.[^4] The `httpBatchLink` terminating link batches multiple simultaneous calls into one HTTP request automatically.[^7]

Run it in a second terminal (leave the server running):

```bash
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:

```text
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:

```ts
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.

```bash
# 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:

```bash
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:

```bash
npx tsc --noEmit
# (no output = success)
```

## Troubleshooting

- **Every client call returns a 404, or `Unable to transform response from server`.** Your server `basePath` and client `url` disagree. If the server sets `basePath: '/trpc/'`, the client `url` must be `http://localhost:3000/trpc` (not the bare host). This mismatch is a very common first-run bug because the official standalone-server example defaults `basePath` to `'/'` while the client example points at `/trpc` — pick one and keep them in sync.[^2][^3]
- **Browser client fails with a CORS error, but `curl` works.** You forgot `middleware: cors()` on the standalone server. `curl` and server-to-server calls ignore CORS; browsers do not.[^2]
- **`createTRPCProxyClient is deprecated` warning.** Rename it to `createTRPCClient` — same options, new name in v11.[^4]
- **`tsc` error: peer dependency / unsupported TypeScript.** tRPC v11 requires TypeScript ≥ 5.7.2.[^4] Check `npx 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.[^4]

## 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](https://nerdleveltech.com/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](https://nerdleveltech.com/tanstack-router-type-safe-search-params-zod-tutorial) 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](https://nerdleveltech.com/typescript-7-native-compiler-tsgo).

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.

[^1]: tRPC, "Announcing tRPC v11" (21 March 2025). https://trpc.io/blog/announcing-trpc-v11
[^2]: tRPC docs, "Standalone Adapter" (v11), including the `createHTTPServer`, `basePath`, and CORS/`middleware` behavior. https://trpc.io/docs/server/adapters/standalone
[^3]: tRPC docs, "Set up a tRPC Client" (v11) — `createTRPCClient`, `httpBatchLink`, and `client.x.query()/.mutate()` usage. https://trpc.io/docs/client/vanilla/setup
[^4]: 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
[^5]: tRPC docs, "Input & Output Validators" — Standard Schema support (Zod, Valibot, ArkType). https://trpc.io/docs/server/validators
[^6]: tRPC docs, "Error Handling" — `TRPCError` codes and their HTTP status mappings. https://trpc.io/docs/server/error-handling
[^7]: tRPC docs, "HTTP Batch Link" — batching multiple calls into one request. https://trpc.io/docs/client/links/httpBatchLink
