tRPC vs GraphQL (2026)
TL;DR
tRPC wins for TypeScript-only teams. GraphQL wins for multi-client or multi-team APIs. tRPC (2.5M weekly downloads) eliminates the API contract problem entirely for TypeScript monorepos — no schema, no codegen, just type inference. GraphQL (17M+ downloads across clients/servers) is the right choice when you have multiple clients, third-party consumers, or teams working on API and client separately. The wrong answer is using GraphQL for a Next.js app where both sides are TypeScript.
Key Takeaways
- tRPC: ~2.5M weekly downloads (core) — GraphQL: 17M+ across packages
- tRPC requires full TypeScript — GraphQL works with any language
- No codegen with tRPC — types flow automatically from server to client
- GraphQL enables multi-client — one schema serves web, mobile, third parties
- tRPC is smaller — ~45KB vs GraphQL-js ~80KB + client + codegen overhead
How Each Solves the API Contract Problem
Every API has a contract: "this endpoint accepts X and returns Y." The challenge is keeping that contract in sync between server and client as your application evolves. Before tRPC and modern GraphQL tooling, the disconnect was invisible until production:
// The old world: REST without contract enforcement
// Server returns this shape — client doesn't know
app.get('/users/:id', async (req, res) => {
res.json({ id, name, email, role, createdAt });
});
// Client assumes the shape — drift happens silently
const user = await fetch('/users/123').then(r => r.json());
user.nome; // Typo — no compile-time error, fails only at runtime
Both tRPC and GraphQL solve this contract problem, but through fundamentally different mechanisms:
GraphQL creates a shared schema — a type system definition (SDL) that lives in its own file and serves as the single source of truth for both server and client. The schema is validated at startup and clients can discover it via introspection. Type safety for the client requires a codegen step that generates TypeScript types from the schema.
tRPC skips the schema entirely. Your router definition is the contract. TypeScript infers types directly from your server procedures and makes them available to the client through a shared type import. No codegen, no drift, no SDL. The same TypeScript compiler that checks your server code checks your client code against the same types.
Here's the same getUser operation in both:
// tRPC — procedure definition IS the contract
export const usersRouter = router({
getById: publicProcedure
.input(z.object({ id: z.string() }))
.query(async ({ input, ctx }) => {
return ctx.db.user.findUnique({ where: { id: input.id } });
// Return type: User | null — automatically inferred
}),
});
# GraphQL — separate schema file defines the contract
type Query {
user(id: ID!): User
}
type User {
id: ID!
name: String!
email: String!
}
With tRPC, there's one place to look. With GraphQL, the schema and the resolver are separate — you must keep them in sync manually or use a code-first approach (like Pothos or TypeGraphQL) that generates the SDL from TypeScript.
tRPC in Practice
A complete Next.js + tRPC + Zod setup illustrates how little boilerplate the approach requires:
// server/routers/users.ts — define procedures
import { router, publicProcedure, protectedProcedure } from '../trpc';
import { z } from 'zod';
import { TRPCError } from '@trpc/server';
export const usersRouter = router({
getById: publicProcedure
.input(z.object({ id: z.string() }))
.query(async ({ input, ctx }) => {
const user = await ctx.db.user.findUnique({ where: { id: input.id } });
if (!user) throw new TRPCError({ code: 'NOT_FOUND' });
return user; // Prisma infers the return type
}),
create: protectedProcedure
.input(
z.object({
name: z.string().min(1),
email: z.string().email(),
})
)
.mutation(async ({ input, ctx }) => {
return ctx.db.user.create({ data: input });
}),
});
// server/root.ts — combine routers
export const appRouter = router({
users: usersRouter,
posts: postsRouter,
});
export type AppRouter = typeof appRouter; // This is the only export the client needs
// client/app.tsx — full type inference, zero codegen
import { trpc } from '../utils/trpc';
function UserCard({ userId }: { userId: string }) {
const { data: user, isLoading } = trpc.users.getById.useQuery({ id: userId });
// user: { id: string; name: string; email: string; ... } | undefined
// TypeScript knows every field — rename on server breaks build on client
if (isLoading) return <Spinner />;
return <div>{user?.name}</div>;
}
const createUser = trpc.users.create.useMutation();
createUser.mutate({ name: 'Alice', email: 'alice@example.com' });
// Type error if you pass wrong fields or miss required ones ✅
The magic: AppRouter is just a TypeScript type. Import it on the client without importing any server code. The tRPC client uses this type to infer input and output shapes for every procedure at compile time.
GraphQL's Multi-Client Advantage
GraphQL's schema is language-agnostic. This is not a convenience feature — it's a fundamental architectural property that makes GraphQL the only viable choice for certain scenarios.
Consider a product API consumed by multiple client types:
React web app (TypeScript) ─┐
React Native mobile app ─┤→ GraphQL API ← Single schema
Python data pipeline ─┤
Third-party partner apps ─┘
Each client generates its own types from the GraphQL schema using the codegen tool for their language:
- Web and mobile:
graphql-codegengenerates TypeScript types - Python:
ariadne-codegenor manual introspection - Partner apps: introspect the schema and generate clients in any language
tRPC requires TypeScript on both sides. There is no non-TypeScript client for tRPC — the type sharing mechanism is the TypeScript compiler itself. For a public API or one consumed by non-TypeScript code, tRPC is simply not an option.
# GraphQL query — identical structure works from any client
query GetUser($id: ID!) {
user(id: $id) {
id
name
email
posts {
title
createdAt
}
}
}
The field-level selection capability of GraphQL also matters at scale. A mobile client can fetch only the fields that fit on a small screen. A dashboard can fetch everything. A background job can fetch only IDs. With tRPC, you'd need separate procedures or optional fields to achieve the same flexibility.
GraphQL Federation and Microservices
GraphQL Federation (via Apollo Federation or Hive) lets you compose multiple GraphQL schemas into one unified graph. This is essential for large organizations where different teams own different parts of the data model:
# Products service — owns Product type
type Product @key(fields: "id") {
id: ID!
name: String!
price: Float!
}
# Reviews service — extends Product with reviews
extend type Product @key(fields: "id") {
id: ID! @external
reviews: [Review!]!
}
# Clients query through a single composed gateway
query ProductWithReviews($id: ID!) {
product(id: $id) {
name
price
reviews {
rating
comment
}
}
}
The gateway composes these into a single schema and routes subqueries to the appropriate service. Clients see one coherent API without knowing how many services exist underneath.
tRPC has no equivalent Federation story. It is designed for monorepos or single-service deployments where all procedures live in one place. You can run multiple tRPC routers and merge them, but there is no automatic schema composition across team or service boundaries at the governance level that Federation provides.
GraphQL in Practice: Schema-First with Codegen
Understanding GraphQL's actual workflow helps clarify why teams choose it despite the additional tooling overhead. The schema-first approach with graphql-codegen produces a developer experience that is strong, even if it requires more ceremony:
# schema.graphql — the source of truth
type User {
id: ID!
name: String!
email: String!
role: UserRole!
posts: [Post!]!
}
enum UserRole {
USER
ADMIN
}
type Query {
user(id: ID!): User
}
type Mutation {
createUser(input: CreateUserInput!): User!
}
input CreateUserInput {
name: String!
email: String!
}
# codegen.yml — run once to generate TypeScript types
overwrite: true
schema: "schema.graphql"
documents: "src/**/*.graphql"
generates:
src/generated/graphql.ts:
plugins:
- typescript
- typescript-operations
- typescript-react-apollo
After running graphql-codegen, every operation you define in .graphql files gets typed React hooks:
// src/queries/user.graphql
query GetUser($id: ID!) {
user(id: $id) {
id
name
email
role
}
}
// After codegen: useGetUserQuery is fully typed
import { useGetUserQuery } from '../generated/graphql';
function UserProfile({ userId }: { userId: string }) {
const { data, loading } = useGetUserQuery({ variables: { id: userId } });
// data.user: { id: string, name: string, email: string, role: UserRole } | undefined
return loading ? <Spinner /> : <div>{data?.user?.name}</div>;
}
The codegen step runs during development (typically via graphql-codegen --watch) and in CI before type checking. It adds a few seconds to the development loop but provides the same end-to-end type safety as tRPC — just with an explicit intermediate artifact.
Choosing an Approach for Next.js Applications
The most common question is whether to use tRPC or GraphQL for a Next.js application. The answer almost always favors tRPC for internal APIs:
A Next.js monorepo with one web client, one engineering team, and a TypeScript codebase is the ideal tRPC scenario. The AppRouter type lives in the api package, the web client imports it, and TypeScript enforces the contract with zero additional tooling. Adding GraphQL here means writing a schema, setting up a GraphQL server (Apollo or Yoga), configuring codegen, and running the codegen in your development workflow — all to solve a problem that tRPC solves with a single type export.
The calculation changes when you add a mobile client. If your React Native app consumes the same API as your web app, GraphQL's multi-client story becomes relevant. You can share the same schema, run codegen independently for each client, and maintain a single source of truth for your data model. tRPC's TypeScript-only constraint is a genuine limitation in this scenario — React Native is TypeScript, but the mobile team may want independence in how they query the API.
The calculation changes further when you have non-TypeScript consumers. A data analytics pipeline in Python, a partner integration in Ruby, or a webhook consumer in Go — none of these can use tRPC's type sharing mechanism. GraphQL's language-agnostic schema and introspection API serve these use cases cleanly.
Key Technical Differences
| tRPC | GraphQL | |
|---|---|---|
| Schema | No separate schema | Required (SDL or code-first) |
| Type safety | Automatic inference | Requires codegen |
| Multiple clients | TypeScript only | Any language |
| Network protocol | HTTP (REST-like) | HTTP (queries/mutations), WebSocket (subscriptions) |
| Introspection | Via TypeScript | Built-in SDL introspection |
| N+1 problem | Manual (DataLoader) | Manual (DataLoader) |
| Learning curve | Low (feels like function calls) | Medium (new query language) |
| Tooling ecosystem | Growing | Mature (Apollo, Relay, urql) |
| Federation / composition | Not supported | Yes (Apollo Federation, Hive) |
Package Health
| Package | Weekly Downloads | Maintainers |
|---|---|---|
@trpc/server | ~2.5M | Alex / KATT, tRPC community |
@trpc/client | ~2.5M | tRPC team |
graphql | ~17M | GraphQL Foundation |
@apollo/client | ~3.5M | Apollo GraphQL (Apollographql) |
graphql-codegen | ~4M (CLI) | The Guild |
tRPC is maintained by a small, dedicated team with strong community backing. It's growing rapidly — up over 200% year-over-year. The team is responsive to issues and the library has a strong semantic versioning track record.
GraphQL (the core graphql package) is maintained by the GraphQL Foundation with broad industry backing. The ecosystem is mature and diversified — Apollo, The Guild, and Relay are all independent teams maintaining major tooling. This breadth is a stability asset.
When to Choose
Choose tRPC when:
- Building a TypeScript monorepo where both server and client are in the same repository
- Your application is a Next.js full-stack app with a single client
- You want zero-overhead type safety without a codegen step or schema file
- Your API is internal and not intended to be consumed by external parties or non-TypeScript code
- Your team wants the simplest possible API layer with the best TypeScript DX
Choose GraphQL when:
- Your API is consumed by multiple client types (web, mobile, third-party partners)
- You have non-TypeScript consumers — Python, Swift, Kotlin, or any other language
- Your organization needs GraphQL Federation to compose schemas across teams and services
- You're exposing a public API where third parties need to introspect and discover capabilities
- Your existing codebase already has a GraphQL schema and you want to leverage it
- Compare tRPC and GraphQL package health on PkgPulse.
- Read our Drizzle vs Kysely comparison for 2026 for the database layer that pairs well with tRPC.
- Explore the GraphQL package page for download trends and ecosystem data.
See the live comparison
View trpc vs. graphql on PkgPulse →