Skip to main content

tRPC vs GraphQL (2026)

·PkgPulse Team
0

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-codegen generates TypeScript types
  • Python: ariadne-codegen or 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

tRPCGraphQL
SchemaNo separate schemaRequired (SDL or code-first)
Type safetyAutomatic inferenceRequires codegen
Multiple clientsTypeScript onlyAny language
Network protocolHTTP (REST-like)HTTP (queries/mutations), WebSocket (subscriptions)
IntrospectionVia TypeScriptBuilt-in SDL introspection
N+1 problemManual (DataLoader)Manual (DataLoader)
Learning curveLow (feels like function calls)Medium (new query language)
Tooling ecosystemGrowingMature (Apollo, Relay, urql)
Federation / compositionNot supportedYes (Apollo Federation, Hive)

Package Health

PackageWeekly DownloadsMaintainers
@trpc/server~2.5MAlex / KATT, tRPC community
@trpc/client~2.5MtRPC team
graphql~17MGraphQL Foundation
@apollo/client~3.5MApollo 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

The 2026 JavaScript Stack Cheatsheet

One PDF: the best package for every category (ORMs, bundlers, auth, testing, state management). Used by 500+ devs. Free, updated monthly.