APIs
|stacknotice.com
14 min left|
0%
|2,800 words
APIs

GraphQL vs REST vs tRPC (2026): Choosing Your API Style

REST is universal. GraphQL solves over-fetching with a typed schema. tRPC gives TypeScript teams end-to-end type safety with no code generation. When each one wins.

C
Carlos Oliva
Software Developer
August 13, 202614 min read
Share:
GraphQL vs REST vs tRPC (2026): Choosing Your API Style

Your API style is not just a technical decision — it determines how your team thinks about data, how your clients evolve independently of your server, and how much friction exists between backend changes and frontend code.

REST has been the default for 15 years because it maps cleanly to HTTP semantics and works with every client that can make an HTTP request. GraphQL emerged to solve specific problems REST creates at scale: over-fetching (the server sends more data than the client needs), under-fetching (the client needs multiple roundtrips to get related data), and the proliferation of bespoke endpoints for each UI view. tRPC sidesteps both by leaning into TypeScript — no schema language, no code generation, the types from your server functions become your client types.

Each approach makes different tradeoffs. None is universally correct.

The Same Resource, Three Ways

A user profile endpoint that returns a user with their recent posts:

REST

// GET /users/:id
// GET /users/:id/posts?limit=5
 
// Server
app.get('/users/:id', authenticate, async (req, res) => {
  const user = await db.user.findUnique({
    where: { id: req.params.id },
    select: { id: true, name: true, email: true, bio: true, avatarUrl: true, createdAt: true }
  })
  if (!user) return res.status(404).json({ error: 'User not found' })
  res.json(user)
})
 
app.get('/users/:id/posts', authenticate, async (req, res) => {
  const posts = await db.post.findMany({
    where: { authorId: req.params.id, published: true },
    take: Number(req.query.limit) || 5,
    orderBy: { createdAt: 'desc' },
    select: { id: true, title: true, slug: true, createdAt: true }
  })
  res.json(posts)
})
 
// Client — two round trips
const user = await fetch(`/api/users/${userId}`).then(r => r.json())
const posts = await fetch(`/api/users/${userId}/posts?limit=5`).then(r => r.json())
// TypeScript types maintained manually or via OpenAPI codegen

GraphQL

// Server — Apollo Server or Pothos
import { createYoga, createSchema } from 'graphql-yoga'
import { builder } from './builder'  // Pothos schema builder
 
builder.queryField('user', t =>
  t.field({
    type: User,
    args: { id: t.arg.string({ required: true }) },
    resolve: async (_, { id }, ctx) => {
      const user = await ctx.db.user.findUnique({ where: { id } })
      if (!user) throw new GraphQLError('User not found', { extensions: { code: 'NOT_FOUND' } })
      return user
    }
  })
)
 
builder.objectType(User, {
  fields: t => ({
    id: t.exposeID('id'),
    name: t.exposeString('name'),
    bio: t.exposeString('bio', { nullable: true }),
    recentPosts: t.field({
      type: [Post],
      args: { limit: t.arg.int({ defaultValue: 5 }) },
      resolve: (user, { limit }, ctx) =>
        ctx.db.post.findMany({
          where: { authorId: user.id, published: true },
          take: limit,
          orderBy: { createdAt: 'desc' }
        })
    })
  })
})
 
// Client — one query, client controls the shape
const query = `
  query GetUserProfile($id: ID!, $postLimit: Int) {
    user(id: $id) {
      id
      name
      bio
      recentPosts(limit: $postLimit) {
        id
        title
        slug
      }
    }
  }
`
 
const { data } = await client.query({ query, variables: { id: userId, postLimit: 5 } })
// data.user is typed (with codegen) — no extra fields included

tRPC

// Server
const usersRouter = router({
  getProfile: publicProcedure
    .input(z.object({
      id: z.string().cuid(),
      postLimit: z.number().int().min(1).max(20).default(5)
    }))
    .query(async ({ input }) => {
      const user = await db.user.findUnique({ where: { id: input.id } })
      if (!user) throw new TRPCError({ code: 'NOT_FOUND' })
 
      const posts = await db.post.findMany({
        where: { authorId: input.id, published: true },
        take: input.postLimit,
        orderBy: { createdAt: 'desc' },
        select: { id: true, title: true, slug: true, createdAt: true }
      })
 
      return { user, posts }
    })
})
 
// Client — typed, no query language, no codegen
const { data } = trpc.users.getProfile.useQuery({ id: userId, postLimit: 5 })
// data.user and data.posts are fully typed from the server function's return type

All three solve the problem. The code volume is similar at this scale. The differences emerge as the API grows.

Type Safety: The Spectrum

This is the most practical difference for TypeScript teams.

REST: Types Are Your Responsibility

// You define and maintain types manually, or use OpenAPI codegen
interface User {
  id: string
  name: string
  email: string
  bio: string | null
  createdAt: string
}
 
// openapi-typescript generates types from your OpenAPI spec
// npx openapi-typescript openapi.yaml -o types.ts
import type { paths } from './types'
 
type GetUserResponse = paths['/users/{id}']['get']['responses']['200']['content']['application/json']
// Breaks if the server changes and you don't regenerate

GraphQL: Types From Schema (With Codegen)

// graphql-codegen generates TypeScript types from your schema
// codegen.ts
const config = {
  schema: 'http://localhost:4000/graphql',
  documents: ['src/**/*.graphql'],
  generates: {
    'src/__generated__/graphql.ts': {
      plugins: ['typescript', 'typescript-operations', 'typescript-react-apollo']
    }
  }
}
 
// Generated types are accurate — if schema changes, codegen fails
// Query types reflect exactly what the query requests, not the full schema type
type GetUserProfileQuery = {
  user: {
    id: string
    name: string
    bio: string | null
    recentPosts: Array<{ id: string; title: string; slug: string }>
  }
}

The codegen step is the friction point. Types are only accurate after you run the generator, and the generator must match the current schema. In practice this means a codegen step in CI and discipline around keeping it current.

tRPC: Types Without a Schema or Generator

// No schema language. No codegen step. No types to maintain.
// The TypeScript type of your router IS the client contract.
 
export type AppRouter = typeof appRouter
 
// On the client:
import type { AppRouter } from '../server/router'
import { createTRPCReact } from '@trpc/react-query'
 
const trpc = createTRPCReact<AppRouter>()
 
// trpc.users.getProfile.useQuery({ id }) is typed at the call site
// If the server changes the return type, the TypeScript compiler fails in the client immediately
// No regeneration, no sync step, no drift

tRPC's type safety is the strictest and the most frictionless — but it only works for TypeScript clients. A mobile app in Swift or an Android app in Kotlin can't consume a tRPC API without wrapping it in a REST or RPC adapter.

Over-fetching and Under-fetching

REST: Both Problems Exist

REST endpoints return fixed shapes. A /users/:id endpoint that returns { id, name, email, bio, avatarUrl, lastLoginAt, settings, ...20 more fields } sends all of it regardless of whether the caller needs one field or all of them.

The traditional solution — create endpoint variants like /users/:id/summary — creates endpoint proliferation. Each frontend view gets its own endpoint, and the backend becomes a set of bespoke data loaders for specific UIs.

GraphQL: Solved by Design

# Profile page — needs everything
query ProfilePage($id: ID!) {
  user(id: $id) {
    name, bio, avatarUrl, followerCount
    recentPosts(limit: 5) { title, slug, createdAt }
  }
}
 
# Comment list — only needs the author's name and avatar
query CommentList($postId: ID!) {
  comments(postId: $postId) {
    id, content, createdAt
    author { name, avatarUrl }  # only these two fields, not the whole user
  }
}

Each client asks for exactly what it needs. The server resolves only those fields. This is GraphQL's core value proposition — it's most meaningful when you have multiple clients (web, mobile, third-party) with different data requirements from the same data model.

tRPC: Controlled by Procedure Design

// tRPC doesn't solve over-fetching at the query level — the procedure returns what it returns
// You control it through procedure design
 
const postsRouter = router({
  // Returns full post for the post page
  getById: publicProcedure.input(z.object({ id: z.string() }))
    .query(async ({ input }) => db.post.findUnique({ where: { id: input.id } })),
 
  // Returns summary fields for listing
  listSummaries: publicProcedure.input(z.object({ limit: z.number().default(20) }))
    .query(async ({ input }) => db.post.findMany({
      take: input.limit,
      select: { id: true, title: true, slug: true, createdAt: true }  // no body field
    })),
})

tRPC doesn't have field-level selection — you design procedures for specific use cases. This is fine for TypeScript-only backends serving one frontend, but scales less cleanly when multiple clients with different needs share the same API.

Caching

REST: HTTP Cache is Native

// REST responses cache at every layer — browser, CDN, reverse proxy
res.set({
  'Cache-Control': 'public, max-age=60, stale-while-revalidate=300',
  'ETag': etag(JSON.stringify(user))
})
 
// CDN (Cloudflare, Fastly) can cache GET responses automatically
// Browser caches GET responses via HTTP headers
// Varnish/nginx can cache at the reverse proxy level

REST's alignment with HTTP semantics means caching is built into the infrastructure that already exists. A CDN in front of your REST API can serve millions of requests for the same user profile without the request reaching your server.

GraphQL: HTTP Caching Is Harder

GraphQL typically uses POST requests (even for reads), which HTTP caches don't cache by default. Solutions exist — GET requests for queries with the query in URL params, persisted queries, Apollo's response caching — but they require explicit configuration.

At the application level, Apollo Client and urql provide sophisticated normalized caches that are more capable than HTTP caching. At the CDN level, GraphQL requires extra work.

tRPC: React Query Manages Caching

tRPC's client is built on React Query. Caching is managed in the React Query cache — sophisticated (deduplication, background refetch, stale-while-revalidate), but client-side only. CDN caching for tRPC requires wrapping procedures as GET requests with query params, which tRPC supports but isn't the default.

Client Flexibility

RESTGraphQLtRPC
Browser (fetch)TypeScript only
Mobile (Swift/Kotlin)
Third-party integrations
Non-TypeScript clients
curl / PostmanPossible

REST and GraphQL are language-agnostic. tRPC only works for TypeScript clients. If your API will ever be consumed by a mobile app, a third-party developer, or a team using a different language, tRPC is not a viable primary API style.

Team and Scale Dynamics

REST fits when:

  • Multiple teams with different clients (mobile, web, third-party)
  • Non-TypeScript consumers
  • CDN caching is important
  • API is public or has external consumers
  • Simple data requirements — each endpoint has one clear use case

GraphQL fits when:

  • Multiple frontend applications with different data requirements from the same models
  • Mobile and web clients that need field-level control over data shape
  • Rapidly evolving frontend requirements — new queries don't require backend changes
  • Strong schema ownership and code generation discipline in the team

tRPC fits when:

  • Full-stack TypeScript with a single frontend application
  • You want type safety without a schema language or code generation
  • Your backend and frontend are in the same codebase (monorepo)
  • API is private — no external consumers

Hybrid Approaches

These styles aren't mutually exclusive.

// REST for public API + tRPC for internal dashboard
const app = express()
app.use('/api/v1', publicRestRouter)   // External API — REST with versioning
 
// tRPC for the internal admin dashboard
app.use('/trpc', trpcExpressMiddleware({ router: appRouter }))
 
// Or: GraphQL for the mobile app, tRPC for the web app
// Same database queries, different API layers

Many production systems use GraphQL for mobile and public APIs, tRPC for internal tooling, and REST for webhooks and third-party integrations — because the right choice depends on the consumer, not the backend.

Decision Framework

SituationChoose
Public API with external consumersREST
Mobile + web + third-party clientsREST or GraphQL
Multiple frontends with different data shapesGraphQL
CDN caching is criticalREST
Full-stack TypeScript, single frontendtRPC
Monorepo, backend + frontend same codebasetRPC
Team allergic to code generationtRPC
Non-TypeScript clients existREST or GraphQL
Real-time subscriptionsGraphQL

The practical heuristic: if someone other than your TypeScript frontend team will consume this API, tRPC is not the right primary choice. If you have multiple clients with genuinely different data needs, GraphQL earns its complexity. If you have one TypeScript frontend and want the tightest possible type loop, tRPC wins on developer experience.

For tRPC in depth — procedures, context, middleware, and Next.js integration — see the tRPC + Next.js complete guide. For GraphQL with TypeScript including schema design and codegen, see the GraphQL + TypeScript guide. For the comparison between tRPC and Hono RPC specifically, the tRPC vs Hono RPC breakdown covers that comparison in detail.

#graphql#trpc#typescript#apis#backend
Share:
C
Carlos Oliva
Software Developer · stacknotice.com

Software developer with hands-on experience building production apps with React, Next.js, Angular, TypeScript, and Spring Boot. I write practical guides on Claude Code, AI tools, and modern web development — covering the decisions and trade-offs that senior-level tutorials actually explain.

More about Carlos

Enjoyed this article?

Get weekly insights on Claude Code, React, and AI tools — practical guides for developers who build real things.

No spam. Unsubscribe anytime. By subscribing you agree to our Privacy Policy.