For a TypeScript monorepo, end-to-end type safety changes how you build. tRPC delivers it without codegen; GraphQL requires more ceremony but supports varied clients. This post is the working comparison.
tRPC
// server.ts
import { initTRPC } from "@trpc/server";
import { z } from "zod";
const t = initTRPC.create();
export const appRouter = t.router({
user: t.router({
byId: t.procedure
.input(z.object({ id: z.number() }))
.query(async ({ input }) => db.user.findUnique({ where: { id: input.id } })),
create: t.procedure
.input(z.object({ email: z.string().email() }))
.mutation(async ({ input }) => db.user.create({ data: input })),
}),
});
export type AppRouter = typeof appRouter;
// client.ts
import type { AppRouter } from "../server/router";
import { createTRPCProxyClient } from "@trpc/client";
const trpc = createTRPCProxyClient<AppRouter>({...});
const user = await trpc.user.byId.query({ id: 1 });
// ^ User | null — fully typed, no codegen
Types flow from server to client. Refactor a procedure → client errors at compile time. No codegen step.
When tRPC wins
- TS monorepo where client and server share code.
- Tight team / controlled clients (no third parties).
- Iteration speed matters — type changes propagate instantly.
When tRPC loses
- Non-TS clients (mobile, third party). They’d need to manually consume HTTP.
- Multiple consumers with varied needs. tRPC procedures are RPCs; clients can’t shape responses.
- Public APIs for unknown integrators.
GraphQL
type User {
id: ID!
email: String!
posts(limit: Int): [Post!]!
}
type Query {
user(id: ID!): User
}
const { data } = await client.query({
query: gql`
query { user(id: 1) { email posts(limit: 5) { title } } }
`
});
Each client picks the fields and depth it wants. One endpoint, many response shapes.
When GraphQL wins
- Multiple clients (web, mobile, admin) with varying needs.
- Federation: many backend services unified into one schema.
- Avoiding overfetching matters (mobile bandwidth).
When GraphQL loses
- Simple resource shapes — REST is simpler.
- Caching — POST-based queries don’t fit CDN caches naturally.
- Setup overhead — schemas, resolvers, dataloaders, codegen all need maintenance.
REST
const user = await fetch("/api/users/1").then(r => r.json());
Boring. Universal. Cacheable. See API Versioning .
When REST wins
- Public APIs for any consumer.
- Resource-shaped data.
- HTTP cache matters.
End-to-end types in REST
REST + OpenAPI + codegen:
openapi-typescript-codegen --input openapi.yaml --output src/api
Generated TS client. Same type safety as tRPC, more ceremony, works for any client.
For a TS-only monorepo: tRPC is leaner. For mixed clients: REST + OpenAPI gives types and universality.
Decision matrix
| Need | Pick |
|---|---|
| TS monorepo, controlled clients | tRPC |
| Multi-client, varied views | GraphQL |
| Public API for any consumer | REST |
| Mobile + web shared codebase | tRPC if RN; otherwise GraphQL |
| Federation across services | GraphQL Federation |
| Simple CRUD | REST |
| TS-everywhere with great DX | tRPC |
Performance
For typical web apps:
- REST: HTTP caching wins for static-ish data.
- GraphQL: avoids overfetching but has Apollo / dataloader overhead.
- tRPC: minimal overhead; ~same as REST.
Protocol perf rarely dominates. DB and rendering matter more.
Mutations
// tRPC
await trpc.user.create.mutate({ email: "..." });
// GraphQL
await client.mutate({
mutation: gql`mutation { createUser(email: "...") { id } }`,
});
// REST
await fetch("/api/users", { method: "POST", body: JSON.stringify({...}) });
All work. tRPC reads as plain function calls — most ergonomic for TS.
Subscriptions / streaming
| Approach | |
|---|---|
| tRPC | subscription procedures over WebSocket |
| GraphQL | Subscriptions over WS (Apollo) |
| REST | SSE or WebSocket sidecar |
GraphQL has the most mature subscription story. tRPC catching up. REST: roll your own.
Hybrid
Common pattern in 2026:
- REST for public API (third parties).
- tRPC for the TS frontend and BFF.
Different consumers, different protocols.
Common mistakes
1. tRPC for public APIs
Third-party Python client has nothing. Stick to REST/GraphQL for external.
2. GraphQL with no query depth limit
Malicious queries can DoS. Always limit depth, complexity, rate.
3. Fighting tRPC in a SSR/RSC framework
Server Components already give type-safe RPC. Adding tRPC on top is redundant.
4. Adopting GraphQL “for the future”
Without varied clients, GraphQL adds setup cost without benefit.
5. No N+1 prevention in GraphQL
Resolver fetches per item; 100x DB queries. Use DataLoader.
What I’d ship today
For a new TS monorepo (full-stack):
- tRPC if all clients are TS.
- GraphQL if mobile + web with shared schema.
- REST + OpenAPI if I’ll have third-party consumers.
- Mix: REST public + tRPC internal.
Read this next
If you want my tRPC + Hono + Drizzle starter, it’s at rajpoot.dev .
Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .