Skip to main content

tRPC vs GraphQL: API Layer 2026

·PkgPulse Team
0

tRPC vs GraphQL: API Layer 2026

TL;DR

tRPC wins for TypeScript-only monorepos. GraphQL wins when you need a public API or multi-client flexibility. tRPC (2.5M weekly downloads) gives you end-to-end type safety with zero schema files, zero codegen, and zero runtime overhead for type inference — your TypeScript server definition is the client contract. GraphQL (apollo-server: 1.8M weekly downloads) is a query language and runtime that works across every language and client, provides precise field selection to eliminate over-fetching, and has a massive ecosystem of tooling, caching solutions, and federation support for large-scale microservices. If you're building a TypeScript-first full-stack app with Next.js, tRPC is dramatically simpler. If you're building an API that mobile apps, third-party developers, or multiple frontend teams will consume, GraphQL's language-agnostic schema is the right foundation.

Key Takeaways

  • tRPC: ~2.5M weekly downloads vs GraphQL (graphql-js): ~7M weekly downloads (npm, March 2026)
  • tRPC has zero codegen — type safety comes from TypeScript inference at compile time; GraphQL requires schema-first codegen (graphql-codegen) for type safety
  • Bundle size: tRPC client ~12KB gzipped; Apollo Client ~45KB gzipped; urql ~25KB gzipped
  • Over-fetching: GraphQL solves it natively with field selection; tRPC requires separate procedures or input projection
  • Subscriptions: both support real-time — tRPC via WebSockets, GraphQL via subscription type
  • Caching: Apollo Client and urql have normalized caches; tRPC delegates to TanStack Query
  • Non-TypeScript clients: GraphQL works everywhere; tRPC is TypeScript/JavaScript-only by design
  • Federation/microservices: Apollo Federation and GraphQL mesh are mature; tRPC has no official multi-service federation story

The Core Difference: Inference vs Schema

The fundamental architectural difference between tRPC and GraphQL is where type information lives.

GraphQL defines a schema as a separate artifact — a .graphql SDL file (or code-first equivalent) that is the contract between server and client. The schema is language-agnostic; a Python backend and a Swift iOS app can both conform to it. Type safety on the TypeScript client requires running codegen (graphql-codegen) to generate TypeScript types from that schema — an extra build step and an extra artifact to keep in sync.

tRPC has no schema. Your TypeScript server router definition is the source of truth. The client imports the server's TypeScript type (not the implementation, just the type) and gets full inference. No codegen, no schema files, no build step. If you rename a field on the server and forget to update the client, TypeScript errors at compile time.

This is the core tradeoff: GraphQL's schema-first approach enables polyglot environments and external consumers at the cost of complexity. tRPC's inference-first approach enables zero-friction TypeScript DX at the cost of JavaScript monoculture.


tRPC

tRPC defines your API as TypeScript procedures — queries (read operations) and mutations (write operations) — grouped in a router. The client infers types from the router type without importing any server code.

Server Setup

// server/trpc.ts
import { initTRPC, TRPCError } from '@trpc/server'
import { z } from 'zod'

const t = initTRPC.context<{ userId: string | null }>().create()

export const router = t.router
export const publicProcedure = t.procedure

export const protectedProcedure = t.procedure.use(async ({ ctx, next }) => {
  if (!ctx.userId) {
    throw new TRPCError({ code: 'UNAUTHORIZED' })
  }
  return next({ ctx: { userId: ctx.userId } })
})
// server/routers/posts.ts
import { router, publicProcedure, protectedProcedure } from '../trpc'
import { z } from 'zod'

export const postsRouter = router({
  list: publicProcedure
    .input(z.object({
      limit: z.number().min(1).max(100).default(20),
      cursor: z.string().optional(),
    }))
    .query(async ({ input, ctx }) => {
      const posts = await ctx.db.post.findMany({
        take: input.limit + 1,
        cursor: input.cursor ? { id: input.cursor } : undefined,
        orderBy: { createdAt: 'desc' },
      })
      const nextCursor = posts.length > input.limit ? posts.pop()?.id : undefined
      return { posts, nextCursor }
    }),

  create: protectedProcedure
    .input(z.object({
      title: z.string().min(1).max(200),
      body: z.string().min(1),
      tags: z.array(z.string()).max(10),
    }))
    .mutation(async ({ input, ctx }) => {
      return ctx.db.post.create({
        data: { ...input, authorId: ctx.userId },
      })
    }),

  onNewPost: publicProcedure
    .subscription(({ ctx }) => {
      return observable<Post>((emit) => {
        const handler = (post: Post) => emit.next(post)
        postEmitter.on('created', handler)
        return () => postEmitter.off('created', handler)
      })
    }),
})

// server/root.ts
export const appRouter = router({ posts: postsRouter })
export type AppRouter = typeof appRouter

Client Usage (React + TanStack Query)

// utils/trpc.ts
import { createTRPCReact } from '@trpc/react-query'
import type { AppRouter } from '../../server/root'

export const trpc = createTRPCReact<AppRouter>()
// Note: only the TYPE is imported — no server code shipped to the client
// components/PostList.tsx
import { trpc } from '../utils/trpc'

function PostList() {
  // Fully typed — cursor type inferred from server definition
  const { data, fetchNextPage, hasNextPage } = trpc.posts.list.useInfiniteQuery(
    { limit: 20 },
    { getNextPageParam: (page) => page.nextCursor }
  )

  const createPost = trpc.posts.create.useMutation({
    onSuccess: () => {
      trpc.useUtils().posts.list.invalidate()
    },
  })

  return (
    <div>
      {data?.pages.flatMap((p) => p.posts).map((post) => (
        // post.title, post.body, post.tags — all typed, no annotation needed
        <article key={post.id}>
          <h2>{post.title}</h2>
        </article>
      ))}
      {hasNextPage && (
        <button onClick={() => fetchNextPage()}>Load more</button>
      )}
    </div>
  )
}

Next.js App Router Integration

// app/api/trpc/[trpc]/route.ts
import { fetchRequestHandler } from '@trpc/server/adapters/fetch'
import { appRouter } from '../../../../server/root'
import { createContext } from '../../../../server/context'

const handler = (req: Request) =>
  fetchRequestHandler({
    endpoint: '/api/trpc',
    req,
    router: appRouter,
    createContext,
  })

export { handler as GET, handler as POST }

Request Batching

tRPC automatically batches multiple queries fired in the same tick into a single HTTP request:

// These fire as ONE request: GET /api/trpc/posts.list,posts.featured
const [list, featured] = await Promise.all([
  trpc.posts.list.query({ limit: 10 }),
  trpc.posts.featured.query(),
])

Batching is opt-out (not opt-in) — it happens automatically, reducing round trips in component trees that fire multiple queries on mount.


GraphQL

GraphQL is a query language for APIs and a runtime for executing queries against your data. The client specifies exactly which fields it needs, the server returns exactly those fields — no more, no less.

Schema Definition (SDL)

# schema.graphql
type Post {
  id: ID!
  title: String!
  body: String!
  tags: [String!]!
  author: User!
  createdAt: String!
}

type User {
  id: ID!
  name: String!
  email: String!
  posts(limit: Int, offset: Int): [Post!]!
}

type Query {
  posts(limit: Int, cursor: String): PostConnection!
  post(id: ID!): Post
  me: User
}

type Mutation {
  createPost(input: CreatePostInput!): Post!
  updatePost(id: ID!, input: UpdatePostInput!): Post!
}

type Subscription {
  postCreated: Post!
}

input CreatePostInput {
  title: String!
  body: String!
  tags: [String!]
}

Resolver Implementation (Apollo Server)

// resolvers/post.ts
import { Resolvers } from './__generated__/types'

export const postResolvers: Resolvers = {
  Query: {
    posts: async (_, { limit = 20, cursor }, ctx) => {
      const posts = await ctx.db.post.findMany({
        take: limit + 1,
        cursor: cursor ? { id: cursor } : undefined,
        orderBy: { createdAt: 'desc' },
        include: { author: true },
      })
      const hasNextPage = posts.length > limit
      return {
        edges: posts.slice(0, limit).map((post) => ({ node: post, cursor: post.id })),
        pageInfo: { hasNextPage, endCursor: posts[limit - 1]?.id },
      }
    },
  },

  Mutation: {
    createPost: async (_, { input }, ctx) => {
      if (!ctx.userId) throw new GraphQLError('Unauthorized', {
        extensions: { code: 'UNAUTHORIZED' },
      })
      return ctx.db.post.create({
        data: { ...input, authorId: ctx.userId },
      })
    },
  },

  Subscription: {
    postCreated: {
      subscribe: () => pubsub.asyncIterator(['POST_CREATED']),
    },
  },

  Post: {
    // Field-level resolver — only called when client requests this field
    author: async (post, _, ctx) => {
      return ctx.db.user.findUnique({ where: { id: post.authorId } })
    },
  },
}

Client Query (Apollo Client)

// queries/posts.ts — define fragment once, reuse everywhere
import { gql } from '@apollo/client'

export const POST_FRAGMENT = gql`
  fragment PostFields on Post {
    id
    title
    tags
    createdAt
    author {
      id
      name
    }
  }
`

export const GET_POSTS = gql`
  query GetPosts($limit: Int, $cursor: String) {
    posts(limit: $limit, cursor: $cursor) {
      edges {
        node { ...PostFields }
        cursor
      }
      pageInfo { hasNextPage endCursor }
    }
  }
  ${POST_FRAGMENT}
`
// components/PostList.tsx
import { useQuery } from '@apollo/client'
import { GetPostsQuery, GetPostsQueryVariables } from './__generated__/graphql'
import { GET_POSTS } from '../queries/posts'

function PostList() {
  const { data, fetchMore } = useQuery<GetPostsQuery, GetPostsQueryVariables>(GET_POSTS, {
    variables: { limit: 20 },
  })

  // Apollo normalizes cache by __typename + id — post updates anywhere
  // in the cache automatically re-render all components using that post

  return (
    <div>
      {data?.posts.edges.map(({ node: post }) => (
        <article key={post.id}>
          <h2>{post.title}</h2>
          {/* Only the fields you requested are available — body not fetched */}
        </article>
      ))}
    </div>
  )
}

Codegen for Type Safety

GraphQL's type safety on the TypeScript client requires running codegen:

npm install --save-dev @graphql-codegen/cli @graphql-codegen/typescript
# codegen.ts
import type { CodegenConfig } from '@graphql-codegen/cli'

const config: CodegenConfig = {
  schema: 'http://localhost:4000/graphql',
  documents: ['src/**/*.tsx', 'src/**/*.ts'],
  generates: {
    'src/__generated__/': {
      preset: 'client',
      plugins: [],
    },
  },
}

export default config

You run graphql-codegen as a build step (or --watch in dev). The generated types are precise — GetPostsQuery knows exactly which fields you requested in that specific query, not just the full schema type.


Type Safety: Inference vs Codegen

This is the sharpest practical difference between the two approaches.

tRPC: Zero-Step Type Safety

// Server changes this return type:
getPost: publicProcedure
  .input(z.object({ id: z.string() }))
  .query(async ({ input }) => {
    return db.post.findUnique({
      where: { id: input.id },
      select: { id: true, title: true, publishedAt: true } // added publishedAt
    })
  })

// Client immediately gets the error — no codegen step needed:
const { data } = trpc.posts.getPost.useQuery({ id: '123' })
data?.publishedAt // ✅ TypeScript knows this exists
data?.body        // ❌ TypeScript error — not in the select

The type flows through at compile time. Change the server, get errors in the client immediately, before any build step.

GraphQL: Codegen-Dependent Type Safety

# You update the schema:
type Post {
  publishedAt: String  # added
}
// Client query — if you don't add publishedAt to the query and re-run codegen:
const { data } = useQuery(GET_POSTS)
data?.posts.edges[0].node.publishedAt  // 🔴 TypeScript error: not in generated type
// (because codegen generates types from your query document, not just the schema)

After adding publishedAt to the query document and re-running graphql-codegen, you get precise types. The codegen approach is actually more precise for GraphQL — the type reflects exactly which fields you requested in each specific query, not the full type. But it requires the extra step.


Performance: Batching, Caching, and Subscriptions

Request Batching

tRPC batches automatically. Multiple useQuery calls in the same render tick become one HTTP request with comma-separated procedure names.

GraphQL can batch via HTTP batching (sending an array of operations), but most implementations use DataLoader to batch resolver calls to the database:

// DataLoader prevents N+1 queries in GraphQL resolvers
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))
})

// Resolvers call loader.load(id) — DataLoader batches into one DB query
Post: {
  author: (post, _, ctx) => ctx.userLoader.load(post.authorId),
}

Caching

Apollo Client has a normalized in-memory cache. When you fetch a post with id: "123", it's stored in the cache as Post:123. Every query that includes that post will read from and write to the same cache entry — updates propagate automatically across your entire component tree.

// After createPost mutation — Apollo updates cache automatically
// if you configure update or use cache.modify
const [createPost] = useMutation(CREATE_POST, {
  update(cache, { data }) {
    cache.modify({
      fields: {
        posts(existingPosts = []) {
          const newPostRef = cache.writeFragment({
            data: data.createPost,
            fragment: POST_FRAGMENT,
          })
          return { ...existingPosts, edges: [{ node: newPostRef }, ...existingPosts.edges] }
        },
      },
    })
  },
})

tRPC delegates caching entirely to TanStack Query. TanStack Query's cache is query-key-based (not normalized) — it caches by the procedure name + inputs. If you update a post, you need to invalidate the queries that contain it:

const updatePost = trpc.posts.update.useMutation({
  onSuccess: (updatedPost) => {
    // Invalidate queries that might contain this post
    utils.posts.list.invalidate()
    utils.posts.getById.invalidate({ id: updatedPost.id })
    // No normalized cache — can't auto-propagate the update
  },
})

For most apps, TanStack Query's invalidation-based cache is simpler to reason about. Apollo's normalized cache is more powerful but requires careful configuration to avoid stale data bugs.

Subscriptions

Both support subscriptions, but the transport differs.

tRPC subscriptions use WebSockets (via @trpc/server/adapters/ws) or SSE:

// Server
onNewPost: publicProcedure.subscription(() =>
  observable<Post>((emit) => {
    const handler = (post: Post) => emit.next(post)
    postEmitter.on('created', handler)
    return () => postEmitter.off('created', handler)
  })
)

// Client
const { data: latestPost } = trpc.posts.onNewPost.useSubscription(undefined, {
  onData(post) {
    utils.posts.list.setData({ limit: 20 }, (old) => ({
      ...old,
      posts: [post, ...(old?.posts ?? [])],
    }))
  },
})

GraphQL subscriptions use WebSocket or SSE transport (graphql-ws is the modern standard):

// Apollo Client WebSocket setup
const wsLink = new GraphQLWsLink(createClient({
  url: 'ws://localhost:4000/graphql',
}))

const splitLink = split(
  ({ query }) => {
    const definition = getMainDefinition(query)
    return definition.kind === 'OperationDefinition' &&
      definition.operation === 'subscription'
  },
  wsLink,
  httpLink,
)

// Component
const { data } = useSubscription(POST_CREATED_SUBSCRIPTION)

Multi-Client and Polyglot Scenarios

This is where GraphQL has a decisive advantage.

GraphQL is language-agnostic. Your schema is the contract. A React frontend, a React Native app, a Swift iOS app, an Android Kotlin app, and a Python data pipeline can all consume the same GraphQL API with type-safe clients generated from the schema in their respective languages.

# TypeScript: graphql-codegen
# Swift: Apollo iOS
# Kotlin: Apollo Kotlin
# Python: ariadne, strawberry
# Go: gqlgen
# Ruby: graphql-ruby

tRPC is TypeScript/JavaScript only. This is intentional — the type safety depends on TypeScript's type system. If you have non-TypeScript clients, you need a separate API (or a REST/GraphQL shim on top of tRPC, which defeats the purpose).

There are community projects to generate OpenAPI specs or REST endpoints from tRPC routers (trpc-openapi), but tRPC's core design assumes TypeScript consumers.


Developer Experience Comparison

Adding a New Endpoint

tRPC — add a procedure to the router, use it from the client:

// Server: add one function
newFeature: protectedProcedure
  .input(z.object({ x: z.number() }))
  .query(({ input }) => ({ result: input.x * 2 }))

// Client: call it with full types
const { data } = trpc.newFeature.useQuery({ x: 5 })

No schema update, no codegen, no type file to check in.

GraphQL — update schema, update resolvers, run codegen:

# 1. Add to schema
type Query {
  newFeature(x: Int!): NewFeatureResult!
}
type NewFeatureResult {
  result: Int!
}
// 2. Add resolver
Query: { newFeature: (_, { x }) => ({ result: x * 2 }) }
# 3. Run codegen
graphql-codegen
// 4. Write query document
const NEW_FEATURE = gql`query NewFeature($x: Int!) { newFeature(x: $x) { result } }`

// 5. Use it — now typed
const { data } = useQuery(NEW_FEATURE, { variables: { x: 5 } })

The GraphQL path has more steps but each step is well-structured. In teams with strict API governance, the schema review step is a feature, not a bug.

Error Handling

tRPC uses TRPCError with typed error codes:

throw new TRPCError({
  code: 'NOT_FOUND',
  message: 'Post not found',
})

// Client receives typed error
trpc.posts.getById.useQuery({ id }, {
  onError(err) {
    if (err.data?.code === 'NOT_FOUND') {
      // TypeScript knows the possible codes
    }
  },
})

GraphQL uses errors array in the response (not HTTP error codes by default):

throw new GraphQLError('Post not found', {
  extensions: { code: 'NOT_FOUND' },
})

// Client error handling — less typed by default
const { error } = useQuery(GET_POST)
if (error?.graphQLErrors[0]?.extensions?.code === 'NOT_FOUND') { ... }

tRPC's error model is more TypeScript-ergonomic. GraphQL's error model is more HTTP-standard but requires extra effort to make type-safe.


Feature Comparison

FeaturetRPC v11GraphQL (Apollo)
Weekly downloads~2.5Mgraphql-js: ~7M
Bundle size (client)~12KB gzippedApollo Client: ~45KB; urql: ~25KB
TypeScript setupZero configRequires codegen
Code generation required✅ (for type safety)
Language-agnostic clients❌ (TS/JS only)
OpenAPI / REST compatibleVia adapterVia REST plugins
Normalized cache❌ (TanStack Query)✅ (Apollo/urql)
Subscriptions✅ WebSocket/SSE✅ WebSocket/SSE
Field-level selection❌ (by procedure)✅ (per query)
Federation/microservices❌ (no standard)✅ Apollo Federation
Introspection / schema explorer
Automatic request batchingVia HTTP batching
Error type safety✅ TRPCError codesPartial (extensions)
Learning curveLowMedium-high

When to Choose tRPC

  • TypeScript monorepo: Your frontend and backend share a single repo and are both TypeScript
  • Full-stack Next.js apps: tRPC integrates seamlessly with Next.js App Router and is the foundation of the T3 Stack
  • Small to medium teams: One or two developers ship faster without managing schema files and codegen pipelines
  • DX speed is the priority: From "define procedure" to "call from client" is a single step — no intermediate artifacts
  • You're already using TanStack Query: tRPC wraps it cleanly with procedure-level type safety
  • Startup / MVP phase: Minimize tooling overhead, ship faster, and add GraphQL later if you genuinely need it

When to Choose GraphQL

  • Multiple client types: Mobile apps (iOS/Android), web, CLI tools, or third-party integrations that aren't TypeScript
  • Public API: External developers need to introspect your schema, explore it in GraphiQL, and generate clients in their language of choice
  • Microservices / federation: Apollo Federation lets you compose multiple GraphQL services into a unified graph — tRPC has no equivalent
  • Precise over-fetching control: Mobile clients on slow connections benefit from requesting only needed fields per view
  • Large teams with API contracts: Schema review as a formal process catches breaking changes before they ship
  • Existing GraphQL infrastructure: If you have a GraphQL gateway, persisted queries, or Apollo Studio, stay in the ecosystem

Migration Path

tRPC → GraphQL

If you've outgrown tRPC and need GraphQL, the migration is additive — you can expose a GraphQL layer that calls your tRPC procedures:

// graphql/resolvers/posts.ts — wrap tRPC in GraphQL resolvers
import { appRouter } from '../../server/root'
import { createCallerFactory } from '@trpc/server'

const createCaller = createCallerFactory(appRouter)

export const postGraphQLResolvers = {
  Query: {
    posts: async (_, args, ctx) => {
      const caller = createCaller(ctx)
      return caller.posts.list(args)
    },
  },
}

This lets you serve both tRPC (for internal TypeScript clients) and GraphQL (for external consumers) from the same business logic layer.

GraphQL → tRPC

If you're simplifying a GraphQL API that only TypeScript clients consume, tRPC doesn't map 1:1 — you'll replace query/mutation operations with procedures. The business logic (resolvers → handlers) ports over cleanly; the schema and type generation layer disappears.


Methodology

  • npm download data from npmjs.com registry API, March 2026
  • tRPC v11 docs: trpc.io/docs
  • GraphQL spec: spec.graphql.org
  • Apollo Server v4 docs: apollographql.com/docs/apollo-server
  • urql docs: urql.dev
  • graphql-codegen docs: the-guild.dev/graphql/codegen
  • Testing with TypeScript 5.4, Node.js 22, Next.js 15, React 19

See how Zod (used by tRPC for input validation) compares to Yup in Zod vs Yup: TypeScript Validation 2026. For a broader API protocol comparison, see REST vs GraphQL vs gRPC. If you're evaluating full-stack starters that use tRPC, see T3 Stack vs Next.js SaaS Starters 2026.

Compare tRPC and GraphQL packages on PkgPulse.

Compare GraphQL and tRPC package health on PkgPulse.

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.