Context
tRPC and GraphQL solve the same problem, a typed contract between client and server, but they disagree on where that contract lives. In tRPC, the contract is TypeScript itself. The server defines a router, the client imports that router's type and infers each procedure's inputs and outputs for free. There's no schema file, no SDL, no codegen step. The direct consequence is that this only works when client and server are both TypeScript, preferably in the same repository. In GraphQL, the contract is a schema written in SDL, independent of any language. The server exposes that schema, and clients in any language talk to it over HTTP; to get TypeScript types, you run codegen against the schema.
Two opposite profiles come out of that. tRPC couples client and server to the same TypeScript base and, in exchange, removes all the schema and code-generation ceremony: change the router and the client already sees the new types with no contract rebuild. GraphQL does the opposite: it decouples the ends (any language, public clients, separate teams) and solves over- and under-fetching by letting the client pick exactly the fields it wants, at the cost of maintaining SDL, resolvers, codegen, and a tooling ecosystem. It's worth reading the numbers carefully: graphql posts about 42.5M downloads per week, but it's the reference implementation in JavaScript, the base of nearly the entire GraphQL ecosystem, while @trpc/server (about 4M/wk) is one specific server package. It's not a 1:1 popularity contest; it's a sign that GraphQL is an industry standard and tRPC is a very well-solved niche.
When to choose tRPC
tRPC shines when client and server are TypeScript in the same repository and you want the cheapest contract possible: none. You define procedures on the server and consume them on the client with end-to-end inference.
import { initTRPC } from "@trpc/server";
import { z } from "zod";
const t = initTRPC.create();
export const appRouter = t.router({
userById: t.procedure
.input(z.string())
.query(({ input }) => ({ id: input, name: "Ada" })),
});
// o cliente infere input e output deste tipo, sem codegen
export type AppRouter = typeof appRouter;
On the client side, you just import the AppRouter type: calling trpc.userById.query("1") is typed on both ends, the input is string and the output is { id: string; name: string }, without a line of codegen. Rename a field on the server and the client stops compiling right away, in your editor, before any request. It's the shortest feedback loop there is between back and front, and the package is tiny (about 5.6 kB gzip). The limit is hard: this only works TypeScript to TypeScript. A mobile app in Swift, an external partner, or any client that doesn't share your code can't consume tRPC unless you build an HTTP layer on top, and at that point you're already reinventing part of what GraphQL or REST give you out of the box.
When to choose GraphQL
GraphQL makes sense when the API is public, polyglot, or consumed by clients you don't control, and when it matters to let each client ask for only the fields it needs. The contract is a schema in SDL, and it, not your TypeScript code, is the source of truth.
import { buildSchema, graphql } from "graphql";
const schema = buildSchema(`
type User { id: ID!, name: String! }
type Query { userById(id: ID!): User }
`);
const root = {
userById: ({ id }: { id: string }) => ({ id, name: "Ada" }),
};
const result = await graphql({
schema,
source: '{ userById(id: "1") { name } }',
rootValue: root,
});
The source above asks only for name, and that's exactly what comes back: no over-fetching, and no new endpoint when the client needs one more field. Because the schema is SDL, any client (JavaScript, Swift, Python, Go) queries the same API over HTTP, and you get tools like GraphiQL to explore the schema, Apollo, and schema registries to version the contract for free. In TypeScript, strong typing comes back via codegen: you point the generator at the schema and it emits the client's types. The cost is exactly that sum of pieces: SDL, resolvers, codegen, and a much larger package (about 56.6 kB gzip against tRPC's 5.6 kB). For a 100% TypeScript monorepo with a single client, it's too much machinery for a problem that tRPC's inference solves with none.
Verdict
The tiebreaker fits in one question: is every consumer of this API TypeScript that shares your code? If the answer is yes, tRPC erases the entire schema and codegen layer and delivers end-to-end types for free, with the smaller package of the two. If any consumer speaks another language, is public, or lives outside your repository, tRPC simply doesn't reach it, and GraphQL's language-agnostic SDL schema, plus client-side field selection, becomes the right tool, even with the extra machinery.
The numbers shouldn't mislead: graphql's 42.5M downloads measure an entire industry standard, not a head-to-head preference over tRPC. The two serve different shapes of problem. tRPC is for internal full-stack TypeScript, the Next.js-style monorepo where the same team owns back and front. GraphQL is for a contract that outlives and outnumbers your own front end. Don't pick GraphQL just to be safe in a TypeScript monorepo: it's accidental complexity that tRPC's inference makes unnecessary. And don't force tRPC onto a public or polyglot API: it doesn't go beyond TypeScript. Decide by the audience of the API, not by hype, and the choice practically makes itself.