tRPC, typed APIs with no schemas and no generation
tRPC lets you call a server function from a client as though it sat in the same file, with full type hints and a compile error when you change a signature. There is no schema, no code generation, and no separate build step.
The trick is that types are neither transmitted nor generated but inferred. The client imports only the type of the server router, and the compiler derives the shape of every call from it. At runtime nothing remains, since that whole layer disappears at compile time.
The problem it actually solves
Worth naming precisely, since whether you need this tool follows from it.
In an ordinary URL based API the response shape is unknown on the client. You write the interface by hand and it is a promise rather than a fact. When the server renames a field, client code still compiles, and the error surfaces at the user.
A schema plus a generated client fixes that, and it works, while adding a build step and a moment where the description drifts from the implementation. Anyone who has worked with a generated client knows the situation where somebody forgot to regenerate the files.
tRPC removes that drift, since there is nothing to drift. The source of truth is the server code, and client types are derived from it by the compiler.
The price is single and concrete: both sides must be in TypeScript and able to see each other's code. If the mobile app is Swift and the admin panel Python, this technique gives them nothing.
When it is the right choice
Three conditions must hold at once, since missing any one turns the advantage into trouble.
First: one team or one repository. The client has to import a type from the server, so both parts must sit where the compiler sees them.
Second: the API is private. This is an interface for your own frontend, not a contract for outside integrations. There is no description in a standard format, no documentation browser, no URL versioning.
Third: both sides on TypeScript, at reasonably close versions, since type inference can differ between compiler releases.
When any condition fails, an ordinary URL based API or GraphQL is the better pick, and that is not a compromise but the right tool for a different problem.
Router, procedure, context
The whole library rests on three concepts, and once they land the rest is configuration detail.
import { initTRPC } from '@trpc/server'
import { z } from 'zod'
const t = initTRPC.context<Context>().create()
export const router = t.router
export const procedure = t.procedure
export const appRouter = router({
user: router({
get: procedure
.input(z.object({ id: z.string() }))
.query(({ input, ctx }) => ctx.db.user.find(input.id)),
rename: procedure
.input(z.object({ id: z.string(), name: z.string().min(2) }))
.mutation(({ input, ctx }) => ctx.db.user.update(input)),
}),
})
export type AppRouter = typeof appRouterThe last line is the most important in the file. You export the type alone rather than the value, so server code never reaches the client bundle. That distinction gets confused and leads to dragging the entire backend, database driver included, into the browser.
Input validation through Zod plays a double role here. It rejects invalid data at runtime and simultaneously derives the argument type, so you do not declare it separately. Without a validator you lose both at once.
Context is an object built per request, and that is where the database connection and the signed in user land. Extending context with a permission check yields a protected procedure you compose once and use everywhere.
export const authedProcedure = procedure.use(({ ctx, next }) => {
if (!ctx.user) throw new TRPCError({ code: 'UNAUTHORIZED' })
return next({ ctx: { ...ctx, user: ctx.user } })
})Returning a narrowed context from next makes the user field non optional in procedures built on that layer. A small thing that removes dozens of checks from the code.
The client side integration
Something changed here worth knowing, since most guides online describe the older approach.
The classic integration with TanStack Query supplied its own hook variants wrapping the query library. The new integration goes differently: it returns plain option objects you pass to the query library's standard functions.
import { useQuery } from '@tanstack/react-query'
import { useTRPC } from './trpc'
function UserProfile({ id }: { id: string }) {
const trpc = useTRPC()
const { data, isPending } = useQuery(trpc.user.get.queryOptions({ id }))
if (isPending) return <p>Loading</p>
return <h1>{data.name}</h1>
}The gain is practical. Everything you know from the query library works here untranslated: caching, invalidation, prefetching, dependent queries. You learn no separate function set, and the query library's documentation applies directly.
For new projects the authors recommend exactly this approach, and advise existing ones to migrate gradually. Both integrations can sit side by side in one project, which lets you migrate screen by screen rather than in one move.
tRPC against the alternatives
| Option | Source of types | Non TypeScript clients | Pick it when |
|---|---|---|---|
| tRPC | Inference from server code | No | Private API, one repository |
| REST with an OpenAPI description | Schema plus generation | Yes | Public API, many consumers |
| GraphQL | Schema plus generation | Yes | Many clients, differing field sets |
| Server Actions | Inference from function code | No | A Next.js app, mostly forms |
That last row is today's key question on a new Next.js project and deserves an honest answer.
Server actions give the same type safety at zero configuration, since they are built in. You call a function from a component, it executes on the server, the types line up by themselves. For forms and simple write operations that usually suffices, and adding a separate layer has no justification.
tRPC pulls ahead in three situations. When the client is not the same application, a React Native app reaching the same backend for instance. When you need full control over client side caching rather than just refreshing a view after a write. And when the backend should outlive the frontend framework, since server actions bind you to Next.js at a level where leaving means rewriting.
Batching and the network cost
One of the first questions about this library: if every procedure call is an HTTP request, does a screen with ten calls send ten requests.
By default no, since calls made in the same tick get combined into one request and split apart server side. The gain shows especially on mobile connections, where establishing a connection costs more than processing the data.
That mechanism carries a consequence worth knowing. A combined request finishes when the slowest procedure in the batch finishes, so one slow operation delays all the others. If a screen holds fast profile data and a slow statistics computation, putting them in one batch means the user's name appears only once the counting ends.
The fix is simple: separate them deliberately, disabling batching for heavy procedures or placing them in a component that loads independently. Check it in the browser's network tab, since the problem only shows against real response times.
The second point concerns the HTTP method. Read queries go through GET by default, so they can be cached by proxies and content delivery networks. Write operations go through POST and are not cacheable. That distinction matters for public data, where a response can sit at the edge instead of loading your server.
The third point is response size. Type inference works regardless of how many fields you return, so it is easy to get used to handing back the whole database object. The type checks out while fields the interface never displays travel across the network. Selecting columns in the database query deserves more attention here than in a hand written API, precisely because nothing reminds you of the problem.
Subscriptions and data arriving live
Beyond reads and writes there is a third kind of procedure: a subscription, a stream of events flowing from server to client.
It suits notifications, presence indicators, progress on a long task, and anything meant to appear without a refresh. The data shape is typed exactly as with ordinary procedures, so the client side event handler gets hints.
Note though that this is the operationally most expensive part of the library. A held connection needs infrastructure that permits it, and a good share of serverless environments cut it after tens of seconds. Before planning around it, check whether your deployment method holds long connections at all.
For smaller needs, polling every few seconds is a more sensible answer than it appears. It is uglier, while working everywhere, requiring no server side state, and not breaking on a new deployment when every connection drops at once.
Errors and what the client sees
Errors carry their own codes in this library, and that helps more than it sounds.
throw new TRPCError({ code: 'NOT_FOUND', message: 'No such user' })The code maps to an HTTP status, and the client receives it in a fixed shape, so telling a permission failure from a missing resource needs no text parsing.
One thing deserves attention at deployment. The error message reaches the client, the stack trace in production mode does not. That is a good default, though easy to spoil by putting a database exception into the message, table names and a query fragment included. Write messages assuming a stranger will read them.
Separately, set up validation error formatting so the client learns which field failed rather than receiving a blanket rejection. Without it a form can only report that something is wrong, not what.
Common mistakes
The first is importing a value rather than a type in client code. That drags the whole backend into the browser bundle, database driver and environment secrets included. Use import type and check the built bundle size.
The second is a procedure with no input validator. The argument is then of unknown type, and outside data goes straight into a query. A validator supplies type and protection at once, so skipping it costs twice.
The third is one enormous router in a single file. Type inference across several hundred procedures can slow the editor noticeably, and on very large projects reach the compiler's complexity limit. Split into sub routers by area.
The fourth is exposing this API publicly. There is no description in a standard format and no URL versioning, so an outside integrator gets an interface that cannot sensibly be documented.
The fifth is keeping server data in a client state store alongside the query library. That produces a second copy of the truth and a question about which one is current.
The sixth is skipping a transformer for types that do not survive text serialisation. A date turns into a string while the type still claims it is a date, so the error surfaces only when a method gets called.
The seventh is confusing type safety with application safety. A correct type says only that the data has the right shape, and says nothing about whether the caller may see that record. Permission checks belong in middleware or in the procedure itself, regardless of how cleanly everything compiles.
The eighth is forgetting version skew when client and server deploy separately. Types agree at build time, while a user's browser may run an older client talking to a newer server for a while. Plan field removals in two steps: stop using them first, delete them later.
FAQ
Is tRPC suitable for a public API?
No. Types travel through the TypeScript compiler, so a foreign client in another language receives nothing. For an interface aimed at outside consumers use REST with an OpenAPI description or GraphQL, and keep tRPC for your own frontend.
Does tRPC make sense alongside Server Actions?
It depends on the project. In a Next.js application where the only client is that same application, server actions usually suffice. tRPC wins when you have a second client, need full control over caching, or want a backend not tied to the framework.
Do I have to use Zod?
No, other validation libraries are supported too. Using some validator is worthwhile though, since it plays a double role: checking data at runtime and deriving the procedure's input type.
Does it slow the application down?
Not at runtime, since types vanish at compile time and a call is an ordinary HTTP request. The noticeable cost lands on the editor and on compilation when one router holds a very large number of procedures.
How do I move to the new TanStack Query integration?
Gradually, since both can run side by side. The new one returns option objects passed to the query library's standard functions, so migration is mostly swapping hook calls, screen by screen.
Documentation sits on the project site, and the new integration is described in the team's announcement.