{"id":388,"date":"2026-06-27T05:02:36","date_gmt":"2026-06-27T05:02:36","guid":{"rendered":"https:\/\/abrarqasim.com\/blog\/trpc-in-2026-the-type-safe-api-i-actually-reach-for\/"},"modified":"2026-06-27T05:02:36","modified_gmt":"2026-06-27T05:02:36","slug":"trpc-in-2026-the-type-safe-api-i-actually-reach-for","status":"publish","type":"post","link":"https:\/\/abrarqasim.com\/blog\/trpc-in-2026-the-type-safe-api-i-actually-reach-for\/","title":{"rendered":"tRPC in 2026: The Type-Safe API I Actually Reach For"},"content":{"rendered":"<p>Okay, confession time. Three months ago I rewrote a side project&rsquo;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 &ldquo;we are not calling a tRPC endpoint from Swift.&rdquo; Fair enough. So I rolled it all back, wrote OpenAPI handlers, and felt smug for about six months.<\/p>\n<p>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&rsquo;t reach for it.<\/p>\n<h2 id=\"when-trpc-actually-earns-its-place\">When tRPC actually earns its place<\/h2>\n<p>It earns it when both ends are TypeScript and one team owns both. That&rsquo;s the whole pitch in one sentence. If you&rsquo;re shipping a public API, stick with REST or GraphQL. If you have a Go or Python client, also stick with REST. tRPC isn&rsquo;t really a network protocol. It&rsquo;s a code-sharing trick. The &ldquo;API contract&rdquo; is just a TypeScript type the server exports, and your client imports it directly.<\/p>\n<p>That sounds dumb until you realize how much of your day is spent on three problems it just deletes:<\/p>\n<ul>\n<li>making sure the request shape on the client matches the handler on the server<\/li>\n<li>keeping types in sync after you rename a field<\/li>\n<li>writing a manually-curated client SDK for your own backend<\/li>\n<\/ul>\n<p>I used to solve those with OpenAPI generators that were almost-but-not-quite-right and a Slack channel where someone would inevitably say &ldquo;hey did you update the schema?&rdquo; tRPC removes the schema-as-paperwork problem because the schema is the code.<\/p>\n<h2 id=\"what-a-procedure-actually-looks-like\">What a procedure actually looks like<\/h2>\n<p>Here&rsquo;s a Next.js Route Handler I had in a billing app a year ago:<\/p>\n<pre><code class=\"language-ts\">\/\/ app\/api\/invoices\/route.ts\nimport { NextRequest } from &quot;next\/server&quot;;\nimport { z } from &quot;zod&quot;;\nimport { db } from &quot;@\/server\/db&quot;;\n\nconst createInvoice = z.object({\n  customerId: z.string().uuid(),\n  amountCents: z.number().int().positive(),\n  notes: z.string().max(500).optional(),\n});\n\nexport async function POST(req: NextRequest) {\n  const body = await req.json();\n  const parsed = createInvoice.safeParse(body);\n  if (!parsed.success) {\n    return Response.json(\n      { error: parsed.error.flatten() },\n      { status: 400 },\n    );\n  }\n  const invoice = await db.invoice.create({ data: parsed.data });\n  return Response.json(invoice);\n}\n<\/code><\/pre>\n<p>Now the same procedure in tRPC v11:<\/p>\n<pre><code class=\"language-ts\">\/\/ server\/routers\/invoice.ts\nimport { z } from &quot;zod&quot;;\nimport { protectedProcedure, router } from &quot;..\/trpc&quot;;\n\nexport const invoiceRouter = router({\n  create: protectedProcedure\n    .input(\n      z.object({\n        customerId: z.string().uuid(),\n        amountCents: z.number().int().positive(),\n        notes: z.string().max(500).optional(),\n      }),\n    )\n    .mutation(async ({ ctx, input }) =&gt; {\n      return ctx.db.invoice.create({ data: input });\n    }),\n});\n<\/code><\/pre>\n<p>Fewer lines, sure, but that isn&rsquo;t the part I care about. The part I care about is on the client:<\/p>\n<pre><code class=\"language-ts\">const invoice = await trpc.invoice.create.mutate({\n  customerId: &quot;...&quot;,\n  amountCents: 4200,\n});\n<\/code><\/pre>\n<p>If I rename <code>amountCents<\/code> to <code>amountMinor<\/code> on the server, my editor lights up the call site immediately. No regenerated client, no broken Postman collection, no &ldquo;who pushed that breaking change.&rdquo; It&rsquo;s just TypeScript moving across a network boundary.<\/p>\n<p>The full setup is documented in the <a href=\"https:\/\/trpc.io\/docs\/client\/nextjs\/setup\" rel=\"nofollow noopener\" target=\"_blank\">tRPC v11 Next.js App Router guide<\/a>, which is the doc I send people to when they ask where to start.<\/p>\n<h2 id=\"the-zod-tax-which-isnt-really-a-tax\">The Zod tax (which isn&rsquo;t really a tax)<\/h2>\n<p>People sometimes ask if Zod is required. It isn&rsquo;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&rsquo;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.<\/p>\n<p>A pattern I use a lot:<\/p>\n<pre><code class=\"language-ts\">const InvoiceLine = z.object({\n  description: z.string().min(1),\n  quantity: z.number().int().min(1),\n  unitPriceCents: z.number().int().min(0),\n});\n\nexport const CreateInvoiceInput = z.object({\n  customerId: z.string().uuid(),\n  lines: z.array(InvoiceLine).min(1),\n});\n\nexport type CreateInvoiceInput = z.infer&lt;typeof CreateInvoiceInput&gt;;\n<\/code><\/pre>\n<p>Then on the client, I import <code>CreateInvoiceInput<\/code> 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.<\/p>\n<p>If you&rsquo;ve never used it, the <a href=\"https:\/\/zod.dev\" rel=\"nofollow noopener\" target=\"_blank\">Zod docs<\/a> are short enough to read in one sitting. The thing that changed how I write APIs wasn&rsquo;t the validator, it was treating the schema as the single source of truth.<\/p>\n<h2 id=\"trpc-v11-with-tanstack-query\">tRPC v11 with TanStack Query<\/h2>\n<p>v11 finally ships a first-class TanStack Query v5 integration that doesn&rsquo;t make me write parallel hooks. The old <code>useQuery<\/code> and <code>useMutation<\/code> helpers are still there for migration, but I&rsquo;ve moved everything to the query-options-factory pattern:<\/p>\n<pre><code class=\"language-tsx\">const trpc = useTRPC();\nconst queryClient = useQueryClient();\n\nconst invoices = useQuery(\n  trpc.invoice.list.queryOptions({ customerId }),\n);\n\nconst create = useMutation(\n  trpc.invoice.create.mutationOptions({\n    onSuccess: () =&gt;\n      queryClient.invalidateQueries(\n        trpc.invoice.list.queryFilter({ customerId }),\n      ),\n  }),\n);\n<\/code><\/pre>\n<p>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 <code>['invoice', 'list', customerId]<\/code> 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.<\/p>\n<p>If you&rsquo;re moving from older tRPC, the <a href=\"https:\/\/trpc.io\/docs\/client\/tanstack-react-query\" rel=\"nofollow noopener\" target=\"_blank\">v11 TanStack Query integration docs<\/a> walk through the migration. It took me about an afternoon for a medium-sized router.<\/p>\n<h2 id=\"where-i-still-reach-for-rest\">Where I still reach for REST<\/h2>\n<p>I don&rsquo;t think tRPC has &ldquo;won.&rdquo; It has earned a spot in my toolkit for closed-loop TypeScript apps. That&rsquo;s the pitch, not a general replacement for HTTP APIs.<\/p>\n<p>Cases where I still write plain Route Handlers:<\/p>\n<ul>\n<li>Public APIs that other teams or third parties will call. tRPC has a <code>trpc-openapi<\/code> adapter, but the moment your API is a product surface, you want a real OpenAPI spec, not a translation of one.<\/li>\n<li>Webhooks. Stripe is going to POST raw JSON to a URL. Just write the handler.<\/li>\n<li>Mobile clients on non-TypeScript stacks. Swift and Kotlin teams don&rsquo;t want to import a TS type to know your shape.<\/li>\n<li>Anything that benefits from independent versioning, like a slow-moving B2B API where customers can&rsquo;t follow your weekly redeploys.<\/li>\n<\/ul>\n<p>That&rsquo;s not a hedge. It&rsquo;s where my own <a href=\"https:\/\/abrarqasim.com\/blog\/api-design-best-practices-defaults-i-argue-for\" rel=\"noopener\">API design defaults<\/a> come in. The question I keep asking is &ldquo;who calls this and how fast can they update?&rdquo; tRPC answers one specific configuration of that question very well.<\/p>\n<h2 id=\"things-that-still-bite\">Things that still bite<\/h2>\n<p>A few things I run into often enough that they should be on your radar before you commit:<\/p>\n<ul>\n<li><strong>Cold-start cost on serverless.<\/strong> 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.<\/li>\n<li><strong>Error shapes.<\/strong> The default error formatter is fine for prototypes, but if you want consistent error envelopes across your app, set up a custom <code>errorFormatter<\/code> early. Don&rsquo;t try to retrofit it after you&rsquo;ve shipped fifty procedures.<\/li>\n<li><strong>Streaming.<\/strong> v11 supports it via <code>httpBatchStreamLink<\/code>, 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.<\/li>\n<li><strong>Stale clients.<\/strong> I still occasionally rename something on the server and forget that an old client bundle is sitting in someone&rsquo;s browser tab. tRPC won&rsquo;t save you from a stale deploy; only your release process will.<\/li>\n<\/ul>\n<p>None of these are dealbreakers. They&rsquo;re just the day-two stuff nobody writes blog posts about.<\/p>\n<h2 id=\"what-to-try-this-week\">What to try this week<\/h2>\n<p>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 <a href=\"https:\/\/nextjs.org\/docs\/app\/building-your-application\/routing\/route-handlers\" rel=\"nofollow noopener\" target=\"_blank\">Next.js Route Handlers<\/a>, so you don&rsquo;t need a big-bang migration to know whether the model fits the way your team works.<\/p>\n<p>If you&rsquo;re on Express, Fastify, or Hono, there&rsquo;s an adapter for each. I&rsquo;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.<\/p>\n<p>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&rsquo;s stopped being a curiosity and started being the thing I reach for first. I&rsquo;ve been wiring this kind of internal tooling for a few of the <a href=\"https:\/\/abrarqasim.com\/work\" rel=\"noopener\">client projects on my portfolio<\/a>, and the time I now spend not arguing about API contracts is real.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>Why tRPC v11 has become my default for small-team TypeScript apps, what a procedure actually looks like, and where I still write plain REST handlers.<\/p>\n","protected":false},"author":2,"featured_media":387,"comment_status":"","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"rank_math_title":"","rank_math_description":"Why tRPC v11 has become my default for small-team TypeScript apps, what a procedure actually looks like, and where I still write plain REST handlers.","rank_math_focus_keyword":"trpc","rank_math_canonical_url":"","rank_math_robots":"","footnotes":""},"categories":[156,35],"tags":[417,61,399,416,63,190],"class_list":["post-388","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-typescript","category-web-development","tag-api-design-2","tag-nextjs","tag-react-query-2","tag-trpc","tag-typescript","tag-zod"],"_links":{"self":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/388","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/comments?post=388"}],"version-history":[{"count":0,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/388\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media\/387"}],"wp:attachment":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media?parent=388"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/categories?post=388"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/tags?post=388"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}