I have shipped REST, GraphQL, and now tRPC in production. For an internal app with one frontend and one backend, tRPC is the most productive of the three by a wide margin.
Why not GraphQL
GraphQL solves a problem I did not have: multiple consumers with different shape requirements. My frontend and backend are the same deploy, maintained by the same people. GraphQL's type-safety-via-codegen pays off when you own the schema but not the consumers. When you own both, tRPC's direct TypeScript inference is faster and has fewer moving parts.
Router structure
I keep routers tight and domain-aligned. One router per bounded context:
// server/routers/_app.ts
import { router } from '../trpc';
import { postsRouter } from './posts';
import { usersRouter } from './users';
export const appRouter = router({
posts: postsRouter,
users: usersRouter,
});
export type AppRouter = typeof appRouter;
That AppRouter type is the whole magic. It is imported on the client as a type-only import, so there is zero runtime cost for the type safety you get on every call site.
Input validation with Zod
Every procedure gets a Zod schema. I do not trust client input, and I do not trust myself to remember that at 11pm.
const createPost = publicProcedure
.input(z.object({
title: z.string().min(1).max(200),
body: z.string().min(1),
}))
.mutation(async ({ input, ctx }) => {
return ctx.db.post.create({ data: input });
});
The one gotcha
Procedures defined as arrow functions on objects lose this context. If you are wrapping a class-based service, bind it explicitly or wrap in a closure. I lost an afternoon to this.
When tRPC is wrong
Public APIs. Anything a third party will consume. Anything non-TypeScript. For those, give me a boring REST contract and OpenAPI docs.