Skip to main content
← All Articles · · 15 min read

tRPC vs GraphQL: End-to-End Type Safety in 2026

A comprehensive comparison of tRPC and GraphQL in 2026 — developer experience, type safety, performance, schema definition, client libraries, real-time, and when to choose each for your fullstack TypeScript application.

trpcgraphqltypescriptapifullstacktype-safetyreactnext.jsbackend

The way fullstack TypeScript applications communicate between client and server has fundamentally changed. REST APIs with hand-maintained types were the norm five years ago. Today, two approaches dominate teams that prioritize type safety: tRPC and GraphQL. They both promise end-to-end type safety, but through very different architectures, with very different trade-offs.

This guide is for TypeScript developers deciding which approach fits their project in 2026.


Quick Comparison: At a Glance

tRPCGraphQL
Client-server couplingTight (monorepo or shared types)Loose (schema-based contract)
Type generationAutomatic (inferred at compile time)Code-gen required (graphql-codegen)
Schema definitionTypeScript (Zod, Yup)SDL or code-first (TypeGraphQL)
Network layerHTTP (GET/POST)HTTP (POST)
Real-timeSubscriptions via WebSocketSubscriptions via WebSocket
Client flexibilityTypeScript onlyAny language
OverfetchingPossiblePrevented (field selection)
Underfetching/N+1ManualSolved with DataLoader
Learning curveLowHigh
File uploadManualVia multipart spec
Federation/stitchingLimited✅ Full federation support
Introspection/toolingTypeScript IDE supportGraphiQL, Apollo Studio, etc.
Best forFullstack TS monoreposMulti-client APIs, complex schemas

What is tRPC?

tRPC lets you call server-side functions from your client as if they were local — no REST endpoints, no GraphQL queries, no code generation. The magic is TypeScript inference: the client knows the return type of every server function at compile time.

// server/router.ts
import { router, publicProcedure } from "./trpc";
import { z } from "zod";

const appRouter = router({
  user: router({
    list: publicProcedure.query(async () => {
      return await db.user.findMany();
    }),
    getById: publicProcedure
      .input(z.object({ id: z.string() }))
      .query(async ({ input }) => {
        return await db.user.findUnique({ where: { id: input.id } });
      }),
    create: publicProcedure
      .input(z.object({ name: z.string(), email: z.string().email() }))
      .mutation(async ({ input }) => {
        return await db.user.create({ data: input });
      })
  })
});

export type AppRouter = typeof appRouter;
// client/users.tsx
import { trpc } from "../utils/trpc";

function UserList() {
  // TypeScript knows this returns User[] — no manual types needed
  const { data: users } = trpc.user.list.useQuery();

  return (
    <ul>
      {users?.map(user => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}

function CreateUser() {
  const createUser = trpc.user.create.useMutation();

  const handleSubmit = async (data: { name: string; email: string }) => {
    // TypeScript validates input at compile time
    await createUser.mutateAsync(data);
  };
}

The key insight: the server IS the type definition. No schema files, no code generation, no drift between API docs and implementation.

Setting Up tRPC

npm install @trpc/server @trpc/client @trpc/react-query @tanstack/react-query zod
// server/trpc.ts
import { initTRPC } from "@trpc/server";
import { ZodError } from "zod";

const t = initTRPC.context<Context>().create({
  errorFormatter: ({ shape, error }) => ({
    ...shape,
    data: {
      ...shape.data,
      zodError: error.cause instanceof ZodError ? error.cause.flatten() : null
    }
  })
});

export const router = t.router;
export const publicProcedure = t.procedure;
export const protectedProcedure = t.procedure.use(authMiddleware);
// Next.js: pages/api/trpc/[trpc].ts
import { createNextApiHandler } from "@trpc/server/adapters/next";
import { appRouter } from "../../../server/router";

export default createNextApiHandler({
  router: appRouter,
  createContext: ({ req, res }) => ({ req, res, session: getSession(req) })
});

What is GraphQL?

GraphQL is a query language and runtime for APIs. Clients request exactly the fields they need, the server returns exactly those fields. It’s language-agnostic, schema-driven, and designed for complex data relationships.

# Schema definition (SDL)
type User {
  id: ID!
  name: String!
  email: String!
  posts: [Post!]!
  createdAt: DateTime!
}

type Post {
  id: ID!
  title: String!
  content: String
  author: User!
  tags: [String!]!
}

type Query {
  users: [User!]!
  user(id: ID!): User
  posts(limit: Int, offset: Int): [Post!]!
}

type Mutation {
  createUser(name: String!, email: String!): User!
  updateUser(id: ID!, name: String): User!
  deleteUser(id: ID!): Boolean!
}

type Subscription {
  userCreated: User!
}
# Client query — request only what you need
query GetUsers {
  users {
    id
    name
    email
    posts {
      id
      title
    }
  }
}

# With variables
query GetUser($id: ID!) {
  user(id: $id) {
    id
    name
    email
  }
}

GraphQL Server Implementation

// Using Pothos (code-first, TypeScript)
import SchemaBuilder from "@pothos/core";
import PrismaPlugin from "@pothos/plugin-prisma";

const builder = new SchemaBuilder<{ PrismaTypes: PrismaTypes }>({
  plugins: [PrismaPlugin],
  prisma: { client: db }
});

builder.prismaObject("User", {
  fields: (t) => ({
    id: t.exposeID("id"),
    name: t.exposeString("name"),
    email: t.exposeString("email"),
    posts: t.relation("posts")
  })
});

builder.queryField("users", (t) =>
  t.prismaField({
    type: ["User"],
    resolve: (query) => db.user.findMany({ ...query })
  })
);

builder.mutationField("createUser", (t) =>
  t.prismaField({
    type: "User",
    args: {
      name: t.arg.string({ required: true }),
      email: t.arg.string({ required: true })
    },
    resolve: (query, _root, args) =>
      db.user.create({ ...query, data: args })
  })
);

Type Generation with graphql-codegen

# codegen.yml
schema: http://localhost:4000/graphql
documents: "./src/**/*.graphql"
generates:
  ./src/generated/graphql.ts:
    plugins:
      - typescript
      - typescript-operations
      - typescript-react-query
    config:
      reactQueryVersion: 5
npx graphql-codegen

Generated client:

// Auto-generated from your .graphql files
import { useGetUsersQuery, useCreateUserMutation } from "./generated/graphql";

function UserList() {
  const { data } = useGetUsersQuery();
  return <ul>{data?.users.map(u => <li key={u.id}>{u.name}</li>)}</ul>;
}

Type Safety Comparison

This is the core question for TypeScript teams.

tRPC: Zero-Config Type Safety

// No codegen step — types flow automatically
const router = router({
  getUser: publicProcedure
    .input(z.object({ id: z.string() }))
    .query(async ({ input }) => {
      // Return type is inferred: Promise<User | null>
      return await db.user.findUnique({ where: { id: input.id } });
    })
});

// Client knows the exact return type without any manual work
const { data } = trpc.getUser.useQuery({ id: "123" });
// data: { id: string, name: string, email: string } | null | undefined

Changes to the server function are immediately reflected in the client types. TypeScript errors appear at the call site before you even run the code.

GraphQL: Codegen-Required Type Safety

# You define types in SDL...
query GetUser($id: ID!) {
  user(id: $id) {
    id
    name
    email
  }
}
# ...then run codegen to generate TypeScript types
npx graphql-codegen
// Then use the generated types
const { data } = useGetUserQuery({ variables: { id: "123" } });
// data?.user is typed — but only if you ran codegen first

The codegen step adds friction. If you forget to run it after changing the schema, your types are stale. CI pipelines must enforce codegen runs. Modern setups use --watch mode to auto-regenerate, but it’s still a build artifact to manage.

Winner: tRPC for TypeScript monorepos. Winner: GraphQL for multi-language or multi-client scenarios.


Performance

tRPC vs GraphQL Latency

For simple CRUD operations, both are comparable — both send HTTP requests, both parse JSON. The differences appear at scale:

tRPC advantages:

  • No query parsing overhead
  • GET requests for queries (HTTP cache-friendly)
  • No resolver chain

GraphQL advantages:

  • Precise field selection prevents overfetching
  • DataLoader batches N+1 queries automatically
  • Persistent queries reduce payload size
// N+1 problem in tRPC — must be handled manually
const router = router({
  posts: publicProcedure.query(async () => {
    const posts = await db.post.findMany();
    // N+1: one query per post to get the author
    return Promise.all(
      posts.map(async (post) => ({
        ...post,
        author: await db.user.findUnique({ where: { id: post.authorId } })
      }))
    );
  })
});
// DataLoader in GraphQL — batches automatically
import DataLoader from "dataloader";

const userLoader = new DataLoader(async (ids: string[]) => {
  const users = await db.user.findMany({ where: { id: { in: ids } } });
  return ids.map(id => users.find(u => u.id === id));
});

// In resolver — batched into one query regardless of how many posts
const PostType = new GraphQLObjectType({
  fields: {
    author: {
      resolve: (post) => userLoader.load(post.authorId) // batched!
    }
  }
});

For complex data graphs with many relationships, GraphQL + DataLoader is more efficient. For straightforward CRUD, the difference is negligible.


Schema Definition

tRPC with Zod

const userSchema = z.object({
  name: z.string().min(1).max(100),
  email: z.string().email(),
  role: z.enum(["admin", "user", "viewer"]).default("user"),
  age: z.number().int().min(0).max(150).optional()
});

const updateUserSchema = userSchema.partial().extend({
  id: z.string().uuid()
});

const router = router({
  updateUser: protectedProcedure
    .input(updateUserSchema)
    .mutation(async ({ input, ctx }) => {
      if (!canUpdateUser(ctx.user, input.id)) {
        throw new TRPCError({ code: "FORBIDDEN" });
      }
      return await db.user.update({
        where: { id: input.id },
        data: input
      });
    })
});

Zod schemas are TypeScript. They serve as both validation and type definitions. No separate schema language to learn.

GraphQL SDL

enum Role {
  ADMIN
  USER
  VIEWER
}

input UpdateUserInput {
  id: ID!
  name: String
  email: String
  role: Role
  age: Int
}

type Mutation {
  updateUser(input: UpdateUserInput!): User!
}

SDL is explicit and language-agnostic. It’s a contract between client and server that any tool can consume — Postman, mobile apps, browser extensions, other services.


Real-Time (Subscriptions)

tRPC Subscriptions

import { observable } from "@trpc/server/observable";

const router = router({
  onNewMessage: publicProcedure
    .input(z.object({ roomId: z.string() }))
    .subscription(({ input }) => {
      return observable<Message>((emit) => {
        const unsubscribe = messageEmitter.on(input.roomId, (message) => {
          emit.next(message);
        });
        return unsubscribe;
      });
    })
});
// Client
const subscription = trpc.onNewMessage.useSubscription(
  { roomId: "room-1" },
  {
    onData: (message) => {
      setMessages((prev) => [...prev, message]);
    }
  }
);

GraphQL Subscriptions

// With graphql-ws
const schema = new GraphQLSchema({
  subscription: new GraphQLObjectType({
    name: "Subscription",
    fields: {
      messageAdded: {
        type: MessageType,
        args: { roomId: { type: GraphQLString } },
        subscribe: (_, { roomId }) => pubsub.asyncIterator(`ROOM_${roomId}`),
        resolve: (payload) => payload
      }
    }
  })
});

Both approaches work. GraphQL subscriptions have broader tooling support (Apollo DevTools, GraphQL Explorer).


Multi-Client and Federation

This is GraphQL’s clear advantage.

tRPC: TypeScript Clients Only

tRPC is TypeScript-only. A native iOS app, a Python data pipeline, or a partner integration cannot easily consume a tRPC API without significant glue code.

// Non-TypeScript client must hit the raw HTTP endpoint manually
// POST /trpc/user.getById
// Body: {"0":{"json":{"id":"123"}}}
// — undocumented, fragile

GraphQL: Universal Contract

Any client can consume a GraphQL API:

// iOS (Apollo iOS)
let query = GetUserQuery(id: "123")
apollo.fetch(query: query) { result in
  print(result.data?.user?.name)
}
# Python
from gql import gql, Client
from gql.transport.requests import RequestsHTTPTransport

client = Client(transport=RequestsHTTPTransport(url="https://api.example.com/graphql"))
result = client.execute(gql("query { user(id: \"123\") { name email } }"))

Apollo Federation

For microservices, GraphQL Federation lets you compose a unified supergraph from multiple services:

# users service
type User @key(fields: "id") {
  id: ID!
  name: String!
  email: String!
}

# posts service — references User without owning it
extend type User @key(fields: "id") {
  id: ID! @external
  posts: [Post!]!
}

tRPC has no equivalent for multi-service composition.


When to Choose Which

Choose tRPC When:

  • Full-stack TypeScript monorepo — Next.js, Remix, or similar
  • Single client (your own web app)
  • Rapid development — want type safety without build steps
  • Small-medium team where the TypeScript constraint is acceptable
  • Simple to moderate data requirements
  • No need for external API consumers

Choose GraphQL When:

  • Multiple clients — web, iOS, Android, partner integrations
  • Multiple languages in your stack
  • Complex data relationships with many joins
  • Public API that third parties will consume
  • Microservices requiring federation
  • Mobile apps where bandwidth matters (precise field selection)
  • Existing GraphQL investment in tooling (Apollo Studio, Hasura, etc.)

The Honest Middle Ground

Many teams start with tRPC and add a GraphQL layer later for public/mobile APIs. This is a valid pattern — tRPC for internal frontend-backend communication, GraphQL for anything external.


Ecosystem Maturity

tRPC

  • Created 2021, now at v11
  • Deep integration with TanStack Query
  • Growing middleware ecosystem
  • Primary home: Next.js and T3 Stack

GraphQL

  • Created 2015 by Facebook, open-sourced
  • Massive ecosystem: Apollo, Urql, Relay, Pothos, Nexus, TypeGraphQL
  • Major tools: Hasura, GraphQL Yoga, Strawberry (Python), Hot Chocolate (.NET)
  • GraphQL introspection for tooling, playground, documentation generation

The Verdict

tRPC is the best choice for fullstack TypeScript teams building web applications with a single frontend client. The developer experience is exceptional — type safety with zero ceremony, no code generation, immediate feedback on API mismatches.

GraphQL is the right choice when your API must serve multiple clients, multiple languages, or complex data graphs at scale. The ecosystem maturity, tooling, and federation support are unmatched.

If you’re building a Next.js SaaS app with a single web frontend: tRPC. If you’re building an API platform that iOS, Android, and web clients all consume: GraphQL. If you’re not sure: start with tRPC and migrate specific endpoints to GraphQL if the need arises.

Free Download

50 CLI Commands That Save Hours Every Week

The exact Git shortcuts, file search tricks, Docker commands, and productivity hacks that senior developers use daily. Compiled from 5+ years of shipping production code.

  • Git mastery (10 cmds)
  • File & search wizardry
  • Docker & DevOps tricks
  • Process & network tools
  • Productivity boosters
  • macOS & Linux install guide

No spam. 1 email/week max. Unsubscribe anytime.

Found this guide useful? Check out our free developer tools.