tRPC vs GraphQL in 2026: End-to-End Type Safety vs Schema-First APIs
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
The Core Problem Both Solve
Every API has a contract: "this endpoint accepts X and returns Y." The challenge is keeping that contract in sync between server and client. Before tRPC and modern GraphQL tooling:
// The old world: REST without contract enforcement
// Server:
app.get('/users/:id', async (req, res) => {
res.json({ id, name, email, role }); // What fields? Unknown to client.
});
// Client:
const user = await fetch('/users/123').then(r => r.json());
user.nome; // Typo — no error caught at compile time
Both tRPC and GraphQL solve this but with different trade-offs.
tRPC: Type Safety Without a Schema
// Server: define procedures
// server/routers/users.ts
import { router, publicProcedure, protectedProcedure } from '../trpc';
import { z } from 'zod';
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; // Return type inferred from Prisma
}),
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 });
}),
});
// Client: full type inference, zero codegen
// client/app.tsx
import { trpc } from '../utils/trpc';
function UserCard({ userId }: { userId: string }) {
const { data: user } = trpc.users.getById.useQuery({ id: userId });
// user is fully typed: { id: string, name: string, email: string, ... }
// Rename user.nome → TypeScript error immediately ✅
}
const mutation = trpc.users.create.useMutation();
mutation.mutate({ name: 'Alice', email: 'alice@example.com' });
// Type error if you pass wrong fields ✅
The magic: tRPC infers types directly from your router definition. Change the server return type — the client immediately shows TypeScript errors. No codegen step, no drift.
GraphQL: Schema-First Flexibility
# schema.graphql — the single source of truth
type User {
id: ID!
name: String!
email: String!
role: UserRole!
posts: [Post!]!
}
type Query {
user(id: ID!): User
}
type Mutation {
createUser(input: CreateUserInput!): User!
}
input CreateUserInput {
name: String!
email: String!
}
// Server resolver
const resolvers = {
Query: {
user: async (_, { id }: { id: string }, ctx: Context) => {
return ctx.db.user.findUnique({ where: { id } });
},
},
Mutation: {
createUser: async (_, { input }: { input: CreateUserInput }, ctx: Context) => {
return ctx.db.user.create({ data: input });
},
},
};
// Client with code generation (graphql-codegen)
const GET_USER = gql`
query GetUser($id: ID!) {
user(id: $id) {
id
name
email
}
}
`;
// Generated type: GetUserQuery, GetUserQueryVariables
const { data } = useQuery<GetUserQuery, GetUserQueryVariables>(GET_USER, {
variables: { id: userId },
});
// data.user.name ✅ — typed from schema after codegen
GraphQL requires a separate codegen step to get types, but the schema is language-agnostic and can serve any client.
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 | 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) |
When tRPC Is Clearly Better
Full-stack TypeScript monorepo:
my-app/
apps/
web/ → Next.js (client)
api/ → Node.js (server)
packages/
db/ → Prisma schema
In this setup, tRPC is the natural choice. Both sides are TypeScript, the team is the same people, and the API is internal. GraphQL would add unnecessary ceremony: write schema, run codegen, update codegen config on every change.
When GraphQL Is Clearly Better
Multiple client types consuming the same API:
Mobile app (React Native) → GraphQL API ← Web app (React)
← Third-party integrations
← Partner API consumers
GraphQL's introspection lets any client discover the API. Non-TypeScript clients (Swift, Kotlin, Python) can generate their own types. Third parties can build on a public schema.
Field-level data fetching (avoiding over/under-fetching):
# Mobile client: only fetch what the small screen needs
query MobileUserCard($id: ID!) {
user(id: $id) {
name
avatar
}
}
# Dashboard: fetch everything
query UserDashboard($id: ID!) {
user(id: $id) {
name
email
role
posts { title createdAt }
subscription { plan expiresAt }
}
}
Compare tRPC and GraphQL package health on PkgPulse.
See the live comparison
View trpc vs. graphql on PkgPulse →