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.
