Skip to main content

GraphQL vs tRPC: The TypeScript Civil War

· 12 min read
GraphQL Guy

GraphQL vs tRPC

Every few years, the API wars get a new challenger. REST fought GraphQL. GraphQL fought gRPC. Now GraphQL is fighting tRPC, and a loud corner of the TypeScript community insists we can finally delete GraphQL from the stack forever. They're half right, half wrong, and half neither (yes, that's 150%; API debates don't always add up). Here's the honest comparison, the scenarios where each genuinely wins, and a reminder that this debate is only meaningful for a specific slice of the industry.

The Framing Problem​

First, let's be honest about what tRPC is and isn't. tRPC is a TypeScript-native RPC layer. It works between a TypeScript server (usually Node, sometimes Bun or Deno) and a TypeScript client (usually React in Next.js or TanStack Start). If both ends aren't TypeScript, tRPC does not apply to you.

tRPC is not:

  • A cross-language API layer
  • A tool for exposing public APIs to third-party developers
  • Applicable to your iOS or Android apps
  • A replacement for your Java backend's API contract

tRPC is:

  • Best-in-class type safety for fullstack TypeScript projects
  • Very fast to develop with
  • Dependency-free on the wire (no compilation step, no generated code)
  • The TypeScript community's favorite API layer, increasingly

This matters because the "GraphQL vs tRPC" argument is really "should TypeScript teams shipping TypeScript clients to TypeScript backends use GraphQL or tRPC?" For Java teams, Kotlin teams, mobile teams, or public APIs: tRPC isn't in the conversation. GraphQL remains the portable, language-agnostic contract.

How tRPC Actually Works​

The core insight is clever. A tRPC router is just a TypeScript object. Its shape encodes the entire API.

// server/router.ts
import { z } from 'zod';
import { publicProcedure, router } from './trpc';

export const appRouter = router({
movie: router({
getById: publicProcedure
.input(z.object({ id: z.string() }))
.query(async ({ input }) => {
return await db.movie.findUnique({ where: { id: input.id } });
}),
search: publicProcedure
.input(z.object({ query: z.string(), limit: z.number().default(10) }))
.query(async ({ input }) => {
return await db.movie.search(input.query, input.limit);
}),
rate: publicProcedure
.input(z.object({ movieId: z.string(), rating: z.number() }))
.mutation(async ({ input, ctx }) => {
return await movieService.rate(input.movieId, input.rating, ctx.user);
}),
}),
});

export type AppRouter = typeof appRouter;

On the client, you import that AppRouter type (not the runtime code, just the type) and magic happens:

// client/component.tsx
import { trpc } from './trpc-client';

function MoviePage({ id }: { id: string }) {
const { data, isLoading, error } = trpc.movie.getById.useQuery({ id });
const rateMutation = trpc.movie.rate.useMutation();

if (isLoading) return <Spinner />;
if (error) return <ErrorPanel message={error.message} />;
if (!data) return <NotFound />;

return (
<div>
<h1>{data.title}</h1>
<button onClick={() => rateMutation.mutate({
movieId: data.id,
rating: 5
})}>
Rate 5 stars
</button>
</div>
);
}

The client's TypeScript compiler knows that getById expects { id: string } and returns a movie. It knows that rate takes { movieId: string, rating: number }. No codegen. No schema file. No SDL. The types just flow from server to client because both ends are in the same TypeScript project.

The three guards above the return are doing real work, and they are a fair illustration of what the type safety gives you. data is typed as possibly undefined until you have ruled out both the loading state and the error state, so the compiler refuses to let you read data.title before you have handled them. Skip a guard and the build fails, which is the point.

On the wire, a tRPC request is plain HTTP. The request body has the procedure name and input. The response has the result. Nothing fancy.

The Things tRPC Does Extraordinarily Well​

1. Zero ceremony. You write a procedure, you call it from the client. That's the entire workflow. No schema to maintain, no code to generate, no versioning to coordinate.

2. Perfect type inference. Change a procedure's return type, and every caller's type-check fails at compile time. Nothing stale, nothing drifting, nothing to manually sync.

3. IDE feel. Autocomplete on trpc. walks you through every procedure. Hover gives you the input shape. Refactoring renames both ends at once.

4. Tiny wire format. No query planning, no schema introspection, no validation overhead. It's the narrowest possible path from "call a function" to "get a response."

5. Fast to iterate. Add a field to a Zod schema, it's immediately available on the client with correct types. No schema file to update, no code to regenerate.

When tRPC Is Magical
  • TypeScript-everywhere monorepo (server and client in the same repo)
  • Small to medium team (everyone who needs to know the API can fit in the codebase)
  • Next.js or TanStack Start app
  • Fast-moving, prototype-to-production workflow
  • You don't care about mobile clients or third-party consumers

The Things GraphQL Does That tRPC Doesn't​

1. Cross-language clients. An iOS app, an Android app, a Go microservice, a Python ML pipeline, a Java admin panel; all of them can consume the same GraphQL API with generated types in their respective languages. tRPC requires TypeScript on both ends.

2. Schema as a published contract. A third-party developer can read your GraphQL SDL and know exactly what they can do. No access to your TypeScript source required. tRPC lacks an equivalent; the "schema" is a TypeScript type that only exists in the development tooling.

3. Ad-hoc query composition. A GraphQL client can request user { name, orders { total } } or user { name, friends { name } } without either pattern being predefined. tRPC needs a procedure for each shape.

4. Federation. A GraphQL supergraph composed of subgraphs owned by different teams is a tractable architectural pattern. tRPC doesn't have a story for gracefully composing routers owned by independent teams across organizational boundaries.

5. Subscriptions as a first-class concept. GraphQL has real subscriptions with typed schemas. tRPC has subscription support too: WebSockets via wsLink (since v10) and Server-Sent Events via httpSubscriptionLink (added in v11, March 2025). The model is solid for TS-to-TS clients, but the broader ecosystem (Apollo Studio integrations, federation-aware tooling, schema registries) is GraphQL-only.

6. Stability. GraphQL's spec is a decade old, stable, and universally implemented. tRPC breaks semi-regularly; the v10 to v11 jump required non-trivial migration work.

When GraphQL Is The Right Answer
  • Any client that isn't TypeScript (mobile, server-to-server, desktop, embedded)
  • Public APIs with third-party developer consumers
  • Federated architecture with multiple backend teams
  • Schema stability over a multi-year horizon
  • Non-TS backends (Java, Python, Go, Rust, .NET)

The Fair Comparison Table​

DimensiontRPCGraphQL
Client languagesTypeScript onlyAny language with a client
Type safetyNative TS, no codegenVia codegen from SDL
SchemaImplicit (derived from TS types)Explicit (SDL file)
Over-fetchingPossible (server defines shape)Client controls the shape
Under-fetchingPossible (need multiple calls)Usually one round trip
CachingRequest-basedField-based and request-based
EcosystemGrowing, TS-centricMature, cross-language
FederationNoMature (Apollo, Cosmo, Hive)
Public API friendlinessPoor (schema not portable)Good (SDL is the contract)
DX in TS monorepoBest in classGood, but with ceremony
Wire protocolPlain HTTP JSONHTTP with query documents
SubscriptionsYes, but less matureYes, mature
AI agent friendlinessWeak (no introspection)Excellent (via MCP)

The Convergence Pattern Nobody Expected​

Here's the twist nobody predicted three years ago: a lot of 2026 applications use both.

Browser (TS)
└── tRPC ──→ Next.js API route ──┐
├── GraphQL gateway ──→ Subgraphs
└── GraphQL ──→ Public API ──────┘ (Java, Go, Python services)

iOS app ───────→ GraphQL gateway
Android app ───→ GraphQL gateway

The logic: your web app is TypeScript top to bottom, so tRPC is perfect for the "make this button work" path. But your organization's actual API surface, the one that powers mobile apps, partner integrations, and internal tools, is GraphQL. The TypeScript web app gets tRPC for velocity, GraphQL for anything that crosses a service boundary or a client-language boundary.

This pattern shows up so consistently in teams I've talked to that I've stopped being surprised by it. It's not "GraphQL vs tRPC." It's "tRPC for the last mile, GraphQL for everything that isn't the last mile."

The REST Question​

Oh right, REST still exists. And it remains the dominant API style by a wide margin wherever anyone measures it directly. Postman's annual State of the API report has put REST at the top of the most-used API architectures every year it has asked, by a comfortable lead over GraphQL, gRPC and everything else.

REST is not dead. REST won't die. REST is the boring default that works for public APIs, simple CRUD, and any context where client simplicity matters more than client efficiency. The right mental model for 2026:

Your SituationBest Choice
Public API for third-party developers, broad language supportREST (OpenAPI)
TypeScript fullstack, same-repo server + clienttRPC
Complex client needs, multiple client languagesGraphQL
Microservices aggregation with multiple backend teamsGraphQL (federation)
Internal CRUD admin panelREST or tRPC, depending on stack
Realtime-heavy with typed eventsGraphQL subscriptions or tRPC subscriptions
Server-to-server, performance-criticalgRPC

Most mature companies use three or four of these at once. The "which API layer" question is usually scoped to a specific use case, not a whole company's architecture.

The Hype Problem​

If you spend time on dev Twitter in 2026, you could be forgiven for thinking tRPC has won. Every TypeScript meta-framework blog post mentions it. Every "you don't need GraphQL" article uses it as the alternative. What you are reading there is a small group of people talking very loudly, and it does not describe the industry.

Look at what companies actually run in production, and the picture is different. GraphQL adoption keeps growing in enterprises - GitHub, Shopify, Netflix, Airbnb (Viaduct), GitLab, PayPal all run public or platform-scale GraphQL. tRPC's slice of the API-layer market remains a TypeScript + Next.js niche, large in that niche but small overall. (You will find specific market-share percentages quoted for this in a lot of comparison posts. Chase any of them back and they lead to another comparison post, not to a survey, so this paragraph gives you the direction and no number.)

Both tools are growing. Neither is displacing the other. The claim that tRPC is killing GraphQL comes almost entirely from people who work only in TypeScript and spend a lot of time on social media. It does not describe what most backend teams are doing.

The Signal in the Noise
  • If your entire stack is TypeScript and you ship to the web only, tRPC is genuinely excellent. The hype is deserved within its niche.
  • If any part of your stack isn't TypeScript, or any of your clients isn't a web browser, tRPC is out of the conversation.
  • GraphQL is thriving in enterprise, mobile, and cross-team contexts. These are the contexts that generate the most dollar-value tech decisions, even if they generate less Twitter content.

The Parts Nobody Talks About​

tRPC Breaks Types If You Do It Wrong​

The client's type safety depends on importing the server's AppRouter type. If your project is one Next.js app, this is trivial. If your client is in a separate deployment from your server, or your server is published as a package consumed by multiple clients, the type sharing gets awkward. You end up either:

  • Building a shared package just for the types (which starts to feel like a schema)
  • Generating types across packages (which starts to feel like codegen)
  • Giving up on full type safety (which defeats the point of tRPC)

Once you're generating types or publishing schema packages, tRPC starts to look weirdly similar to GraphQL with extra steps.

GraphQL Has Real Operational Complexity​

On the other side: GraphQL's tooling is mature because it needs to be. Query complexity analysis, persisted queries, DataLoaders for N+1, federation, schema evolution, field-level auth. All of this is ecosystem work that tRPC skips because its model is simpler.

tRPC's simplicity is real and not an illusion. You don't need a federation gateway. You don't need query cost analysis. You don't need DataLoader. Procedures are just functions; if they're slow, you profile them like normal code.

For small teams and small apps, this is liberating. For large teams with complex data, you eventually build a lot of what GraphQL gives you anyway, just with more custom code.

The Answer for Your Team​

Skip the Twitter fights. Ask three questions:

  1. Are all your clients TypeScript? If no, you're using GraphQL (or REST). tRPC doesn't serve your team.

  2. Do you have any backend that isn't TypeScript? If yes, you're using GraphQL (or REST). tRPC needs TS on both ends.

  3. Is your primary client a web app shipped from the same repo as the server? If yes, tRPC is a great fit for that specific path. You can still use GraphQL for everything else.

Most real organizations land on "GraphQL for the external contract, tRPC for the internal web stack, REST for the legacy bits we haven't replaced." That's not a compromise; that's a normal, healthy 2026 API architecture.

The API wars aren't zero-sum. Use each tool where it's best.


This post ships without a schema. It works because you and I happen to share a language, which is the assumption the whole debate is about.

Sources​

This post argues that the loudest numbers in this debate are recycled without primary sources, so it cites primary ones.