Skip to main content
Ganesh Joshi
Back to Blogs

REST vs tRPC: API design for full-stack TypeScript

February 15, 20266 min read
Tips
API or backend code on screen

When building full-stack TypeScript applications, you have choices for client-server communication. REST is the universal standard with mature tooling. tRPC offers end-to-end types with minimal boilerplate. Understanding when to use each helps you build better APIs.

REST overview

REST (Representational State Transfer) uses HTTP methods and URLs to model resources:

Method Purpose Example
GET Read resource GET /users/123
POST Create resource POST /users
PUT Replace resource PUT /users/123
PATCH Update resource PATCH /users/123
DELETE Remove resource DELETE /users/123
// Server (Next.js API route)
export async function GET(
  request: Request,
  { params }: { params: { id: string } }
) {
  const user = await db.users.findUnique({ where: { id: params.id } });
  return Response.json(user);
}

// Client
const response = await fetch('/api/users/123');
const user = await response.json(); // Type: any

REST strengths

  • Universal: Any client, any language
  • Cacheable: HTTP caching, CDN support
  • Mature tooling: OpenAPI, Postman, documentation generators
  • Scalable: Stateless, works with any infrastructure
  • Standard: Well-understood conventions

REST challenges

  • Type drift: Client and server types can diverge
  • Boilerplate: Define routes, handlers, and client types separately
  • Overfetching: Fixed response shapes
  • Manual validation: Type safety requires extra tooling

tRPC overview

tRPC exposes procedures (queries and mutations) with automatic type inference:

// Server (router definition)
import { initTRPC } from '@trpc/server';
import { z } from 'zod';

const t = initTRPC.create();

export const appRouter = t.router({
  users: t.router({
    getById: t.procedure
      .input(z.object({ id: z.string() }))
      .query(async ({ input }) => {
        return db.users.findUnique({ where: { id: input.id } });
      }),

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

export type AppRouter = typeof appRouter;

// Client
import { createTRPCProxyClient } from '@trpc/client';
import type { AppRouter } from './server/router';

const trpc = createTRPCProxyClient<AppRouter>({
  links: [httpBatchLink({ url: '/api/trpc' })],
});

const user = await trpc.users.getById.query({ id: '123' });
// Type: { id: string; name: string; email: string }

tRPC strengths

  • End-to-end types: Types flow from server to client automatically
  • No code generation: Types inferred from router definition
  • Validation built-in: Zod schemas validate input and provide types
  • React Query integration: Caching, loading states, optimistic updates
  • Less boilerplate: One router, automatic client

tRPC challenges

  • TypeScript only: Doesn't work for non-TS clients
  • Not a standard: Custom protocol over HTTP
  • HTTP caching harder: POST by default
  • Internal focus: Not designed for public APIs

Comparison table

Aspect REST tRPC
Type safety Manual or generated Automatic
Client support Any language TypeScript only
Caching HTTP native React Query
Documentation OpenAPI, Swagger TypeScript types
Learning curve Well-known Smaller API
Public APIs Standard Not recommended
Boilerplate More Less
Code generation Often needed Not needed

When to use REST

Choose REST when:

  • Public API: External developers consume your API
  • Multi-language clients: Mobile apps, other services in different languages
  • HTTP caching critical: CDN caching, browser caching
  • Team familiarity: Team knows REST, new to tRPC
  • Existing infrastructure: API gateways, monitoring assume REST
// Next.js REST API route
// app/api/users/[id]/route.ts
import { NextResponse } from 'next/server';
import { z } from 'zod';

const ParamsSchema = z.object({ id: z.string().uuid() });

export async function GET(
  request: Request,
  { params }: { params: { id: string } }
) {
  const result = ParamsSchema.safeParse(params);
  if (!result.success) {
    return NextResponse.json({ error: 'Invalid ID' }, { status: 400 });
  }

  const user = await db.users.findUnique({ where: { id: result.data.id } });
  if (!user) {
    return NextResponse.json({ error: 'Not found' }, { status: 404 });
  }

  return NextResponse.json(user);
}

When to use tRPC

Choose tRPC when:

  • Full-stack TypeScript: Same team owns client and server
  • Internal API: Only your app consumes the API
  • Rapid development: Iterate without updating types manually
  • React Query patterns: You want caching, optimistic updates
  • Type safety priority: Catch errors at compile time
// tRPC with Next.js App Router
// server/routers/users.ts
import { z } from 'zod';
import { publicProcedure, router } from '../trpc';

export const usersRouter = router({
  list: publicProcedure
    .input(z.object({
      limit: z.number().min(1).max(100).default(10),
      cursor: z.string().nullish(),
    }))
    .query(async ({ input }) => {
      const items = await db.users.findMany({
        take: input.limit + 1,
        cursor: input.cursor ? { id: input.cursor } : undefined,
      });

      let nextCursor: string | undefined;
      if (items.length > input.limit) {
        const nextItem = items.pop();
        nextCursor = nextItem?.id;
      }

      return { items, nextCursor };
    }),

  create: publicProcedure
    .input(z.object({
      name: z.string().min(1),
      email: z.string().email(),
    }))
    .mutation(async ({ input }) => {
      return db.users.create({ data: input });
    }),
});

Hybrid approach

Use both for different purposes:

┌─────────────────────────────────────────────┐
│                Your App                      │
├─────────────────────────────────────────────┤
│  Internal features          External APIs    │
│  ┌─────────────┐           ┌─────────────┐  │
│  │    tRPC     │           │    REST     │  │
│  │ - Dashboard │           │ - Webhooks  │  │
│  │ - Settings  │           │ - OAuth     │  │
│  │ - Profile   │           │ - Public    │  │
│  └─────────────┘           └─────────────┘  │
└─────────────────────────────────────────────┘
// Hybrid setup
// tRPC for internal app
const trpc = createTRPCNext<AppRouter>({ ... });

// REST for webhooks
export async function POST(request: Request) {
  const webhook = await request.json();
  await processWebhook(webhook);
  return new Response('OK');
}

tRPC vs Server Actions

In Next.js, Server Actions offer similar benefits:

Aspect tRPC Server Actions
Type safety Full Full
React Query Built-in integration Manual setup
Complexity More setup Simpler
Caching React Query Next.js cache
Use case Complex APIs Form submissions

For simple mutations, Server Actions are often simpler. For complex data fetching with caching, tRPC with React Query excels.

Making REST type-safe

If you need REST with type safety:

// Shared types
// types/api.ts
export interface User {
  id: string;
  name: string;
  email: string;
}

export interface ApiResponse<T> {
  data?: T;
  error?: string;
}

// Type-safe fetch wrapper
async function fetchApi<T>(url: string): Promise<ApiResponse<T>> {
  const res = await fetch(url);
  if (!res.ok) {
    return { error: `HTTP ${res.status}` };
  }
  return { data: await res.json() };
}

// Usage
const result = await fetchApi<User>('/api/users/123');
if (result.data) {
  console.log(result.data.name); // Typed!
}

Or use code generation with OpenAPI:

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

Summary

REST is the universal standard for APIs consumed by any client—use it for public APIs, webhooks, and multi-language projects. tRPC provides end-to-end type safety with minimal boilerplate—use it for internal TypeScript APIs where you control both client and server. Many apps benefit from using both: tRPC for the main application, REST for external integrations.

Frequently Asked Questions

tRPC is a TypeScript RPC framework that provides end-to-end type safety between client and server. You define procedures on the server and call them from the client with full type inference—no code generation needed.

tRPC replaces REST for internal TypeScript APIs. For public APIs consumed by external clients or non-TypeScript apps, REST remains the standard. Many apps use both: tRPC internally, REST for integrations.

tRPC queries can be cached using React Query or similar libraries. HTTP caching is harder since tRPC uses POST by default. The httpLink can use GET for queries, enabling some HTTP caching.

Both provide type safety and flexible data fetching. GraphQL requires schema definitions and often code generation. tRPC infers types directly from your TypeScript code. tRPC is simpler for TypeScript-only projects.

tRPC works well with Next.js but competes with Server Actions for similar use cases. Use tRPC for complex APIs with React Query integration. Use Server Actions for simpler form submissions and mutations.

Related Posts