Okay, confession time. Three months ago I rewrote a side project’s REST layer in tRPC for the second time, and I spent the first afternoon explaining to myself why I was doing it again. The first attempt was back in 2022. I had v10 working, the type inference felt almost magical, and then I bailed because my mobile team gave me a flat “we are not calling a tRPC endpoint from Swift.” Fair enough. So I rolled it all back, wrote OpenAPI handlers, and felt smug for about six months.
This year I picked it back up on a TypeScript-only project. Next.js App Router on one end, a React Native client written by the same two-person team on the other. tRPC v11 has quietly become the boring default in my own work, and I want to lay out why, where it earns its place, and where I still don’t reach for it.
When tRPC actually earns its place
It earns it when both ends are TypeScript and one team owns both. That’s the whole pitch in one sentence. If you’re shipping a public API, stick with REST or GraphQL. If you have a Go or Python client, also stick with REST. tRPC isn’t really a network protocol. It’s a code-sharing trick. The “API contract” is just a TypeScript type the server exports, and your client imports it directly.
That sounds dumb until you realize how much of your day is spent on three problems it just deletes:
- making sure the request shape on the client matches the handler on the server
- keeping types in sync after you rename a field
- writing a manually-curated client SDK for your own backend
I used to solve those with OpenAPI generators that were almost-but-not-quite-right and a Slack channel where someone would inevitably say “hey did you update the schema?” tRPC removes the schema-as-paperwork problem because the schema is the code.
What a procedure actually looks like
Here’s a Next.js Route Handler I had in a billing app a year ago:
// app/api/invoices/route.ts
import { NextRequest } from "next/server";
import { z } from "zod";
import { db } from "@/server/db";
const createInvoice = z.object({
customerId: z.string().uuid(),
amountCents: z.number().int().positive(),
notes: z.string().max(500).optional(),
});
export async function POST(req: NextRequest) {
const body = await req.json();
const parsed = createInvoice.safeParse(body);
if (!parsed.success) {
return Response.json(
{ error: parsed.error.flatten() },
{ status: 400 },
);
}
const invoice = await db.invoice.create({ data: parsed.data });
return Response.json(invoice);
}
Now the same procedure in tRPC v11:
// server/routers/invoice.ts
import { z } from "zod";
import { protectedProcedure, router } from "../trpc";
export const invoiceRouter = router({
create: protectedProcedure
.input(
z.object({
customerId: z.string().uuid(),
amountCents: z.number().int().positive(),
notes: z.string().max(500).optional(),
}),
)
.mutation(async ({ ctx, input }) => {
return ctx.db.invoice.create({ data: input });
}),
});
Fewer lines, sure, but that isn’t the part I care about. The part I care about is on the client:
const invoice = await trpc.invoice.create.mutate({
customerId: "...",
amountCents: 4200,
});
If I rename amountCents to amountMinor on the server, my editor lights up the call site immediately. No regenerated client, no broken Postman collection, no “who pushed that breaking change.” It’s just TypeScript moving across a network boundary.
The full setup is documented in the tRPC v11 Next.js App Router guide, which is the doc I send people to when they ask where to start.
The Zod tax (which isn’t really a tax)
People sometimes ask if Zod is required. It isn’t. tRPC accepts any validator with a parser interface, including Yup, Valibot, and ArkType. I keep reaching for Zod because the runtime guard and the static type come from the same definition. I’m not writing TypeScript interfaces next to JSON schema validators next to SQL constraints anymore. The Zod schema is the contract, the parser, and the type.
A pattern I use a lot:
const InvoiceLine = z.object({
description: z.string().min(1),
quantity: z.number().int().min(1),
unitPriceCents: z.number().int().min(0),
});
export const CreateInvoiceInput = z.object({
customerId: z.string().uuid(),
lines: z.array(InvoiceLine).min(1),
});
export type CreateInvoiceInput = z.infer<typeof CreateInvoiceInput>;
Then on the client, I import CreateInvoiceInput from the same module and use it to type a React form. The form, the API call, and the database insert all see the same shape. When I forget a field, the compiler tells me before I push.
If you’ve never used it, the Zod docs are short enough to read in one sitting. The thing that changed how I write APIs wasn’t the validator, it was treating the schema as the single source of truth.
tRPC v11 with TanStack Query
v11 finally ships a first-class TanStack Query v5 integration that doesn’t make me write parallel hooks. The old useQuery and useMutation helpers are still there for migration, but I’ve moved everything to the query-options-factory pattern:
const trpc = useTRPC();
const queryClient = useQueryClient();
const invoices = useQuery(
trpc.invoice.list.queryOptions({ customerId }),
);
const create = useMutation(
trpc.invoice.create.mutationOptions({
onSuccess: () =>
queryClient.invalidateQueries(
trpc.invoice.list.queryFilter({ customerId }),
),
}),
);
It looks like more boilerplate at first. The win is that the query keys, the query functions, and the input types all live behind one helper. No more ['invoice', 'list', customerId] arrays drifting away from the actual call site. When I add a filter parameter to the procedure, the helper picks it up and the invalidation predicate stays valid.
If you’re moving from older tRPC, the v11 TanStack Query integration docs walk through the migration. It took me about an afternoon for a medium-sized router.
Where I still reach for REST
I don’t think tRPC has “won.” It has earned a spot in my toolkit for closed-loop TypeScript apps. That’s the pitch, not a general replacement for HTTP APIs.
Cases where I still write plain Route Handlers:
- Public APIs that other teams or third parties will call. tRPC has a
trpc-openapiadapter, but the moment your API is a product surface, you want a real OpenAPI spec, not a translation of one. - Webhooks. Stripe is going to POST raw JSON to a URL. Just write the handler.
- Mobile clients on non-TypeScript stacks. Swift and Kotlin teams don’t want to import a TS type to know your shape.
- Anything that benefits from independent versioning, like a slow-moving B2B API where customers can’t follow your weekly redeploys.
That’s not a hedge. It’s where my own API design defaults come in. The question I keep asking is “who calls this and how fast can they update?” tRPC answers one specific configuration of that question very well.
Things that still bite
A few things I run into often enough that they should be on your radar before you commit:
- Cold-start cost on serverless. The first call to a router still pays a parse-the-whole-router-shape cost. Smaller, split routers help. Big monolithic ones get sluggish on cold lambdas.
- Error shapes. The default error formatter is fine for prototypes, but if you want consistent error envelopes across your app, set up a custom
errorFormatterearly. Don’t try to retrofit it after you’ve shipped fifty procedures. - Streaming. v11 supports it via
httpBatchStreamLink, and it works, but the developer experience around partial results is rougher than non-streaming calls. Worth knowing before you build a chat UI on top of it. - Stale clients. I still occasionally rename something on the server and forget that an old client bundle is sitting in someone’s browser tab. tRPC won’t save you from a stale deploy; only your release process will.
None of these are dealbreakers. They’re just the day-two stuff nobody writes blog posts about.
What to try this week
Pick one internal page where the request and response shapes have drifted from your TypeScript types. Convert just that endpoint to a single tRPC procedure, keep the rest of your API as-is, and see how it feels. tRPC is happy to share a router tree with Next.js Route Handlers, so you don’t need a big-bang migration to know whether the model fits the way your team works.
If you’re on Express, Fastify, or Hono, there’s an adapter for each. I’d start with one mutation and one query, not a whole feature, because you want to feel the type inference and the editor experience, not pre-judge it from architecture diagrams.
It might not be the right call for your stack. But for mine, small teams with TypeScript on both ends and internal apps shipping fast, it’s stopped being a curiosity and started being the thing I reach for first. I’ve been wiring this kind of internal tooling for a few of the client projects on my portfolio, and the time I now spend not arguing about API contracts is real.