Built byPhoenix

© 2026 Phoenix

← Blog
API DesignRESTGraphQLtRPCTypeScriptArchitecture

The Art of API Design: REST, GraphQL, tRPC, and When to Use Each

Phoenix·February 24, 2026·22 min read

The Art of API Design: REST, GraphQL, tRPC, and When to Use Each

APIs are the contracts that hold modern software together. Every frontend talks to a backend. Every microservice talks to another. Every mobile app, CLI tool, and webhook depends on a well-designed API.

But "well-designed" means very different things depending on which paradigm you choose. REST, GraphQL, and tRPC each solve the same fundamental problem — client-server communication — with radically different philosophies.

This isn't a "which is best" article. It's a "which is best for your situation" guide.


REST: The Standard That Refuses to Die

The Philosophy

REST (Representational State Transfer) maps your data to URLs and uses HTTP verbs to operate on them. It's resource-oriented: you have nouns (resources) and verbs (GET, POST, PUT, DELETE).

GET    /api/users          → List usersGET    /api/users/123      → Get user 123POST   /api/users          → Create a userPUT    /api/users/123      → Update user 123DELETE /api/users/123      → Delete user 123

Why It Works

  • Universal — Every language, framework, and tool speaks HTTP. REST APIs work with curl, Postman, browsers, and any HTTP client.
  • Cacheable — HTTP caching (CDN, browser, proxy) works out of the box. A GET request to the same URL returns cached responses automatically.
  • Stateless — Each request contains everything the server needs. No session state. This makes REST APIs horizontally scalable.
  • Well-understood — Every developer knows REST. Onboarding is instant.

Where It Falls Apart

Over-fetching and under-fetching. This is REST's Achilles heel.

typescript
// You need the user's name and their latest 3 posts// REST requires two requests:const user = await fetch('/api/users/123')        // Returns ALL user fieldsconst posts = await fetch('/api/users/123/posts')  // Returns ALL posts// You got 30 fields you don't need and 200 posts when you only wanted 3

Versioning hell. When your API evolves, you either break clients or maintain multiple versions: /api/v1/users, /api/v2/users, /api/v3/users...

No type safety. The response is whatever JSON the server sends. Your TypeScript types are a lie — they're just your hope of what the response looks like.

When to Use REST

  • Public APIs consumed by external developers
  • Simple CRUD applications
  • APIs that need aggressive HTTP caching
  • When your consumers use many different languages/platforms
  • Webhooks and integrations

GraphQL: Query Exactly What You Need

The Philosophy

GraphQL flips the model. Instead of the server deciding what data to return, the client specifies exactly what it needs in a query language.

graphql
# Client asks for exactly what it needsquery {  user(id: 123) {    name    avatar    posts(limit: 3) {      title      createdAt    }  }}

One request. Exact data. No over-fetching. No under-fetching.

Why It Works

  • Precise data fetching — The client controls the shape of the response. Mobile clients can request less data than desktop clients from the same API.
  • Single endpoint — Everything goes through POST /graphql. No more guessing URL structures.
  • Self-documenting — The schema is the documentation. Tools like GraphiQL let developers explore the API interactively.
  • Strong typing — The schema defines every type, field, and relationship. Codegen tools generate TypeScript types from the schema automatically.
  • Ecosystem — Apollo, Relay, urql, and others provide caching, optimistic updates, and real-time subscriptions out of the box.

Where It Falls Apart

Complexity tax. GraphQL introduces a schema definition language, resolvers, dataloaders (to avoid N+1 queries), and a whole new mental model.

typescript
// A simple REST endpointapp.get('/api/users/:id', async (req, res) => {  const user = await db.users.findById(req.params.id)  res.json(user)})// The GraphQL equivalent requires:// 1. Schema definition// 2. Resolver functions// 3. Type definitions// 4. DataLoader for batching// 5. Context setup// ...that's a lot more code for the same result

Caching is hard. REST caches at the HTTP level (URLs). GraphQL sends POST requests to a single endpoint with varying query bodies — CDN caching breaks completely. You need application-level caching (Apollo Cache, Relay Store).

Security surface. Clients can craft expensive queries that tank your server:

graphql
# A malicious nested queryquery {  user(id: 1) {    posts {      author {        posts {          author {            posts {              # ... infinite nesting            }          }        }      }    }  }}

You need query complexity analysis, depth limiting, and rate limiting to prevent abuse.

File uploads require multipart form-data workarounds. GraphQL wasn't designed for binary data.

When to Use GraphQL

  • Apps with complex, nested data relationships
  • Multiple clients (web, mobile, TV) needing different data shapes from the same API
  • Rapidly evolving frontends where over-fetching is a real performance problem
  • When you have a dedicated frontend team that benefits from schema-first development

tRPC: Delete the API Layer

The Philosophy

tRPC asks a radical question: if your frontend and backend are both TypeScript, why maintain an API contract at all?

Instead of defining schemas, writing routes, generating types, and keeping them in sync — tRPC lets you call backend functions directly from the frontend with full end-to-end type safety. No code generation. No schemas. No runtime overhead.

typescript
// server: define a procedure (just a function)const appRouter = router({  getUser: publicProcedure    .input(z.object({ id: z.string() }))    .query(async ({ input }) => {      return db.users.findById(input.id)      // Return type is automatically inferred    }),  createPost: publicProcedure    .input(z.object({      title: z.string(),      content: z.string(),    }))    .mutation(async ({ input }) => {      return db.posts.create(input)    }),})// Export the type (not the runtime — just the type)export type AppRouter = typeof appRouter
typescript
// client: call it like a functionconst user = await trpc.getUser.query({ id: '123' })// user is fully typed — hover over it in your IDE// Change the server return type? TypeScript errors instantly in the client.const post = await trpc.createPost.mutate({  title: 'Hello',  content: 'World',})

Why It Works

  • Zero API contract maintenance — Change a function on the server, get instant TypeScript errors on the client. No codegen step. No schema syncing.
  • Full-stack type safety — Input validation (Zod), return types, and error types flow from server to client automatically.
  • No overhead — tRPC adds ~2KB to your bundle. Under the hood, it's just HTTP (or WebSockets). No special runtime.
  • Incredible DX — Autocomplete works across the entire stack. Rename a field on the server, and your IDE updates every client reference.
  • Composable — Middleware, context, and subscriptions work naturally. Authentication, logging, and error handling compose like regular functions.

Where It Falls Apart

TypeScript only. If your client isn't TypeScript (Swift, Kotlin, Dart, Python), tRPC gives you nothing. It's a TypeScript-to-TypeScript bridge.

Monorepo bias. tRPC works best when client and server share a type import. In a monorepo, this is trivial. Across separate repos, you need to publish the type package — which adds friction.

Not for public APIs. tRPC generates an RPC-style API (not REST, not GraphQL). External developers can't consume it with Postman or curl in any intuitive way. It's an internal protocol.

Scaling boundaries. When your "backend" splits into multiple services owned by different teams (possibly in different languages), tRPC's single-router model breaks down.

When to Use tRPC

  • Full-stack TypeScript applications (Next.js, SvelteKit, Remix)
  • Internal APIs where the same team owns frontend and backend
  • Rapid prototyping where type safety matters but API contract overhead doesn't
  • Monorepos where client and server share types naturally

The Comparison

AspectRESTGraphQLtRPC
Learning CurveLowHighMedium
Type SafetyManual (OpenAPI → codegen)Schema → codegenAutomatic (zero codegen)
Over/Under-fetchingCommon problemSolvedSolved (you write the query)
CachingHTTP caching (free)Application-level (complex)React Query / TanStack Query
Public APIExcellentGoodPoor
Multi-languageAny languageAny languageTypeScript only
ToolingPostman, curl, everythingGraphiQL, Apollo DevToolsIDE autocomplete
File UploadsNativeWorkaroundsNative (via HTTP)
Real-timeWebSockets / SSE (separate)Subscriptions (built-in)Subscriptions (built-in)
Bundle Size0 (just fetch)~30-50KB (Apollo)~2KB

The Decision Framework

1. Who consumes the API?

  • External developers / public → REST. It's the lingua franca.
  • Your own frontend team → GraphQL or tRPC.
  • Same team, same repo, all TypeScript → tRPC. No contest.

2. How complex is the data?

  • Simple CRUD → REST. Don't overcomplicate it.
  • Deeply nested, relational → GraphQL shines here.
  • Moderate complexity, full-stack TS → tRPC.

3. How many clients?

  • One client (your web app) → tRPC or REST.
  • Multiple clients with different needs → GraphQL.
  • Third-party integrations → REST with OpenAPI docs.

4. What about the hybrid approach?

Modern apps often use multiple paradigms:

  • REST for webhooks and third-party integrations
  • tRPC for the internal web app API
  • GraphQL for the mobile app that needs flexible querying

This isn't indecisive — it's pragmatic.


Real-World Patterns

The Full-Stack TypeScript Stack (My Preference)

typescript
// tRPC + Next.js App Router + TanStack Query// Zero API boilerplate, full type safety// server/routers/user.tsexport const userRouter = router({  me: protectedProcedure.query(async ({ ctx }) => {    return ctx.db.user.findUnique({ where: { id: ctx.user.id } })  }),  updateProfile: protectedProcedure    .input(z.object({ name: z.string().min(1), bio: z.string().optional() }))    .mutation(async ({ ctx, input }) => {      return ctx.db.user.update({        where: { id: ctx.user.id },        data: input,      })    }),})

REST for External + tRPC for Internal

typescript
// Public REST API for third partiesapp.get('/api/v1/products', async (req, res) => {  const products = await db.products.findMany()  res.json(products)})// Internal tRPC for your own dashboardconst dashboardRouter = router({  analytics: adminProcedure.query(async ({ ctx }) => {    return getAnalytics(ctx.organizationId)  }),})

Wrapping Up

There's no "best" API paradigm. There's only the right one for your context:

  • REST when you need universality, caching, and public consumption
  • GraphQL when you need flexible, precise data fetching across multiple clients
  • tRPC when you're building full-stack TypeScript and want to move fast with zero API overhead

The best teams don't pick one religion — they pick the right tool for each boundary in their system.


Further Reading

← All postsShare on X
GET    /api/users          → List usersGET    /api/users/123      → Get user 123POST   /api/users          → Create a userPUT    /api/users/123      → Update user 123DELETE /api/users/123      → Delete user 123
// You need the user's name and their latest 3 posts// REST requires two requests:const user = await fetch('/api/users/123')        // Returns ALL user fieldsconst posts = await fetch('/api/users/123/posts')  // Returns ALL posts// You got 30 fields you don't need and 200 posts when you only wanted 3
# Client asks for exactly what it needsquery {  user(id: 123) {    name    avatar    posts(limit: 3) {      title      createdAt    }  }}
// A simple REST endpointapp.get('/api/users/:id', async (req, res) => {  const user = await db.users.findById(req.params.id)  res.json(user)})// The GraphQL equivalent requires:// 1. Schema definition// 2. Resolver functions// 3. Type definitions// 4. DataLoader for batching// 5. Context setup// ...that's a lot more code for the same result
# A malicious nested queryquery {  user(id: 1) {    posts {      author {        posts {          author {            posts {              # ... infinite nesting            }          }        }      }    }  }}
// server: define a procedure (just a function)const appRouter = router({  getUser: publicProcedure    .input(z.object({ id: z.string() }))    .query(async ({ input }) => {      return db.users.findById(input.id)      // Return type is automatically inferred    }),  createPost: publicProcedure    .input(z.object({      title: z.string(),      content: z.string(),    }))    .mutation(async ({ input }) => {      return db.posts.create(input)    }),})// Export the type (not the runtime — just the type)export type AppRouter = typeof appRouter
// client: call it like a functionconst user = await trpc.getUser.query({ id: '123' })// user is fully typed — hover over it in your IDE// Change the server return type? TypeScript errors instantly in the client.const post = await trpc.createPost.mutate({  title: 'Hello',  content: 'World',})
// tRPC + Next.js App Router + TanStack Query// Zero API boilerplate, full type safety// server/routers/user.tsexport const userRouter = router({  me: protectedProcedure.query(async ({ ctx }) => {    return ctx.db.user.findUnique({ where: { id: ctx.user.id } })  }),  updateProfile: protectedProcedure    .input(z.object({ name: z.string().min(1), bio: z.string().optional() }))    .mutation(async ({ ctx, input }) => {      return ctx.db.user.update({        where: { id: ctx.user.id },        data: input,      })    }),})
// Public REST API for third partiesapp.get('/api/v1/products', async (req, res) => {  const products = await db.products.findMany()  res.json(products)})// Internal tRPC for your own dashboardconst dashboardRouter = router({  analytics: adminProcedure.query(async ({ ctx }) => {    return getAnalytics(ctx.organizationId)  }),})