Skip to main content

tRPC vs GraphQL in 2026: End-to-End Type Safety vs Schema-First APIs

·PkgPulse Team

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

tRPCGraphQL
SchemaNo separate schemaRequired (SDL or code-first)
Type safetyAutomatic inferenceRequires codegen
Multiple clientsTypeScript onlyAny language
Network protocolHTTPHTTP (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)

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.

Comments

Stay Updated

Get the latest package insights, npm trends, and tooling tips delivered to your inbox.