TL;DR
- tRPC is strongest when one TypeScript-speaking team owns both client and server and wants fast iteration with end-to-end inferred contracts.
- Its biggest value is not fewer lines of code; it is eliminating a whole class of client/server type drift.
- I would choose it for full-stack TypeScript product surfaces, internal tools, and route-local backend layers where schema codegen is unnecessary overhead.
- I would not use it as the only contract for polyglot or third-party consumers that need a language-agnostic API surface.
Context
A lot of API friction in web products is not network shape alone. It is contract drift:
- backend changed a field name
- client expected an enum case that no longer exists
- runtime is correct, but generated types are stale
- validation exists on one side and assumptions on the other
tRPC is interesting because it treats the backend router as the source of truth and gives the client inferred types directly from it.
The Approach
1. Use tRPC when ownership is end-to-end
This is where it shines:
- same repository
- same TypeScript type system
- frontend and backend evolve together
- internal app, not public platform surface
import { initTRPC } from '@trpc/server'
import { z } from 'zod'
const t = initTRPC.create()
export const appRouter = t.router({
productById: t.procedure
.input(z.object({ id: z.string() }))
.query(async ({ input, ctx }) => {
const product = await ctx.productService.findById(input.id)
if (!product) {
throw new Error('Product not found')
}
return product
}),
})
export type AppRouter = typeof appRouter
import { createTRPCClient, httpBatchLink } from '@trpc/client'
import type { AppRouter } from './server'
const trpc = createTRPCClient<AppRouter>({
links: [
httpBatchLink({
url: 'http://localhost:3000/trpc',
}),
],
})
const product = await trpc.productById.query({ id: 'sku-123' })
That developer loop is the real product.
2. Keep business boundaries as deliberate as any other API
The type inference is fantastic, but it does not replace architecture judgment.
I still want to be clear about:
- auth boundaries
- tenant context
- validation
- mutation semantics
- cache and invalidation behavior
- which procedures should or should not exist at the edge
3. Real-world fit: internal product surfaces and BFFs
Good fits:
- internal tools where product and backend evolve together
- Next.js or React apps with one full-stack TypeScript team
- BFF-style aggregation layers where the client benefits from inferred contracts
Poor fits:
- mobile apps in other languages
- public partner APIs
- ecosystems where language-neutral contracts matter more than TypeScript DX
4. Think hard about lock-in boundaries
tRPC lock-in is not bad by default. But it is real.
If the organization expects:
- non-TypeScript consumers
- public API contracts
- long-lived, tool-independent schemas
then GraphQL, REST, or gRPC may be the better boundary even if developer experience inside one repo is less magical.
Trade-offs
- tRPC dramatically improves TypeScript developer velocity, but only inside a TypeScript-first ecosystem.
- End-to-end inference removes many drift bugs, but it also couples client and server more tightly at the contract layer.
- It is excellent for internal and product-owned APIs, but weaker for public or polyglot API boundaries.
- Schema-first or protocol-first systems cost more ceremony, but they can scale farther across teams and languages.
References
- tRPC Docs
- tRPC
- TanStack Query React Overview
- Related: API Patterns
- Related: Next.js Route Handlers & BFF