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

NeedPick
TS monorepo, controlled clientstRPC
Multi-client, varied viewsGraphQL
Public API for any consumerREST
Mobile + web shared codebasetRPC if RN; otherwise GraphQL
Federation across servicesGraphQL Federation
Simple CRUDREST
TS-everywhere with great DXtRPC

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
tRPCsubscription procedures over WebSocket
GraphQLSubscriptions over WS (Apollo)
RESTSSE 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 .