Skip to content
← Back to articles
9 min read

REST vs tRPC vs GraphQL: Which One in 2026?

A practical comparison of API approaches for full-stack TypeScript apps. When REST wins, when tRPC eliminates it, and when GraphQL is overkill.

REST vs tRPC vs GraphQL: Which One in 2026?
In this post

TL;DR: If you own both the client and server in TypeScript, use tRPC — you get end-to-end type safety with zero code generation. If you’re building a public API or serving multiple non-TypeScript clients, use REST with OpenAPI. GraphQL is the right choice when your frontend team needs flexible data fetching across a complex, multi-entity data graph. Don’t pick based on hype; pick based on your team topology.


Every new project starts with the same debate: how should the frontend talk to the backend?

The answer depends on exactly one thing: who consumes your API? If you answer that honestly, the choice becomes obvious.

Table of Contents

The Decision Framework

Before looking at each option, answer these questions:

  1. Do you own both client and server? (Or is the API consumed by third parties?)
  2. Is everything TypeScript? (Or do you have mobile clients in Swift/Kotlin?)
  3. How complex is your data graph? (Simple CRUD or deeply nested relationships?)
  4. How big is your team? (Solo dev or separate frontend/backend teams?)

These four questions eliminate 90% of the debate.

REST: The Universal Default

REST isn’t exciting, and that’s its advantage. Every developer knows it, every tool supports it, every tutorial assumes it.

When REST Wins

  • Public APIs — External consumers expect REST. They have HTTP clients in every language. They don’t want to install your SDK.
  • Simple CRUD — If your API is mostly “get thing, create thing, update thing, delete thing,” REST maps perfectly.
  • Multi-platform clients — iOS, Android, web, CLI tools. REST is the lowest common denominator in the best way.

The Real Pattern

Modern REST isn’t the “pure REST” from academic papers. Nobody implements HATEOAS. The practical pattern in 2026 is:

// Server — Express/Hono/Fastify
app.get('/api/users/:id', async (req, res) => {
  const user = await db.users.findById(req.params.id);
  if (!user) return res.status(404).json({ error: 'Not found' });
  return res.json(user);
});

// Client — fetch with type assertion
const res = await fetch(`/api/users/${id}`);
const user: User = await res.json(); // ← the type gap

That User type assertion is the problem. The client hopes the server returns a User. If the server changes the response shape, the client breaks at runtime, not compile time.

You can close this gap with OpenAPI:

# openapi.yaml
paths:
  /api/users/{id}:
    get:
      responses:
        200:
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/User'

Then generate client types with openapi-typescript:

npx openapi-typescript ./openapi.yaml -o ./src/api/types.ts

This works. It’s also a maintenance burden — you’re hand-writing YAML that must stay in sync with your server code. For public APIs, this cost is worth it. For internal APIs, there’s a better way.

tRPC: Type Safety Without the Ceremony

tRPC eliminates the API layer entirely. There’s no schema file, no code generation, no HTTP method debates. The server defines procedures, and the client calls them with full TypeScript inference.

When tRPC Wins

  • Full-stack TypeScript — You own both client and server, and both are TypeScript
  • Solo devs or small teams — No coordination overhead between frontend and backend
  • Rapid iteration — Change a server return type and see red squiggles in the client instantly

How It Works

// Server — define your router
import { initTRPC } from '@trpc/server';
import { z } from 'zod';

const t = initTRPC.create();

export const appRouter = t.router({
  getUser: t.procedure.input(z.object({ id: z.string() })).query(async ({ input }) => {
    const user = await db.users.findById(input.id);
    if (!user) throw new TRPCError({ code: 'NOT_FOUND' });
    return user; // Return type is inferred
  }),

  createUser: t.procedure
    .input(z.object({ name: z.string(), email: z.string().email() }))
    .mutation(async ({ input }) => {
      return await db.users.create(input);
    }),
});

export type AppRouter = typeof appRouter;
// Client — full inference, zero codegen
import { createTRPCClient } from '@trpc/client';
import type { AppRouter } from '../server/router';

const trpc = createTRPCClient<AppRouter>({
  /* config */
});

const user = await trpc.getUser.query({ id: '123' });
// user is fully typed — inferred from the server return type
// Hover over `user` in your IDE and see the exact shape

const newUser = await trpc.createUser.mutate({
  name: 'Jordan',
  email: 'not-an-email', // ❌ Zod validation catches this at runtime
});

The magic: AppRouter is just a type import. No server code is bundled into the client. TypeScript’s type system connects the two sides at compile time and erases at runtime.

The Limitation

tRPC requires TypeScript on both ends. If you ever need to serve a mobile app in Swift, a Python script, or a third-party integration, tRPC can’t help them. You’d need to add a REST layer alongside it.

GraphQL: The Flexible Query Language

GraphQL lets the client request exactly the data it needs. No over-fetching, no under-fetching. The server defines a schema, and clients write queries against it.

When GraphQL Wins

  • Complex data graphs — Entities with deep relationships (users → posts → comments → authors)
  • Multiple frontend surfaces — A mobile app needs 3 fields, a web dashboard needs 20, and they hit the same API
  • Separate frontend/backend teams — The schema acts as a contract; teams can work independently

The Real Cost

GraphQL’s flexibility comes at a price:

  1. Schema duplication — You define types in the GraphQL schema AND in TypeScript. Code generators (graphql-codegen) help but add tooling complexity.
  2. N+1 queries — Without a dataloader, a nested query like users { posts { comments } } fires one query per user per post. DataLoader solves this but requires explicit implementation.
  3. Caching complexity — REST responses cache by URL. GraphQL POST requests with unique query bodies don’t cache at the HTTP level. You need a client-side cache (Apollo, urql) or persisted queries.
// The query
const GET_USER = gql`
  query GetUser($id: ID!) {
    user(id: $id) {
      name
      email
      posts(limit: 5) {
        title
        commentCount
      }
    }
  }
`;

// Generated type (via graphql-codegen)
type GetUserQuery = {
  user: {
    name: string;
    email: string;
    posts: Array<{ title: string; commentCount: number }>;
  } | null;
};

This is powerful for complex UIs. It’s overkill for a blog or portfolio site.

The Comparison

REST + OpenAPItRPCGraphQL
Type safetyVia codegen (manual sync)Automatic (inferred)Via codegen (schema-first)
Setup effortLowLowMedium-High
Client flexibilityFixed endpointsFixed proceduresFlexible queries
Multi-language clients✅ Universal❌ TypeScript only✅ Universal
CachingHTTP caching (free)Query-level (manual)Complex (client-side)
Best team sizeAnySolo → smallMedium → large
Learning curveNoneLowModerate

My Recommendation

For most projects in 2026:

Solo dev, full-stack TypeScript? → tRPC. No contest. You get end-to-end type safety with the least tooling overhead. The DX is unmatched.

Building a public API? → REST with OpenAPI. Generate client SDKs for consumers. It’s boring, universal, and cacheable.

Complex data graph, multiple teams? → GraphQL. The schema-as-contract model shines when frontend and backend teams need to iterate independently on a rich data model.

Starting and not sure? → Start with tRPC if you’re in TypeScript. It’s the easiest to add and the easiest to replace. If you later need a public API, add REST routes alongside it. You’re not locked in — they coexist cleanly.

Don’t pick an API strategy because a blog post told you it’s “the future.” Pick it because it solves the specific coordination problem your team has today.

Written by Jordan Thirkle

Stay-at-home dad building AI-accelerated products. I write code during naps and after bedtime — every post comes from real work, not theory.

X GITHUB LINKEDIN NEWSLETTER
0