tRPC vs REST vs GraphQL: Type-Safe APIs in Next.js 2026
TL;DR
tRPC has won the full-stack TypeScript SaaS niche. REST remains the universal default. GraphQL survives in complex data-fetching scenarios. tRPC's 2M+ weekly downloads and zero-boilerplate type safety make it the fastest path to a type-safe Next.js API — no code generation, no schema files, just TypeScript. REST wins when you need a public API, mobile clients, or non-TypeScript consumers. GraphQL wins when clients have genuinely different data requirements and you can afford the operational overhead.
Key Takeaways
- tRPC: 2M weekly downloads, zero API boilerplate, perfect for Next.js monorepos — types cross client/server automatically
- REST: Universal, simple, cacheable — but you write types twice (server + client) or use OpenAPI codegen
- GraphQL: Flexible queries for complex data graphs, but adds a layer of tooling (Apollo/urql, schema, resolvers)
- Bundle size: tRPC client ~10KB, GraphQL Apollo Client ~30KB+, REST needs nothing (fetch)
- For SaaS boilerplates: T3 Stack ships tRPC by default; ShipFast uses REST API routes; none ship GraphQL
- Server Actions: For Next.js internal mutations, Server Actions now compete with all three
Download Trends
| Package | Weekly Downloads | Trend |
|---|---|---|
@trpc/server | ~2.2M | ↑ Growing |
graphql | ~8.5M | → Stable |
apollo-server | ~1.8M | ↓ Declining |
@apollo/client | ~4.2M | → Stable |
urql | ~600K | ↑ Growing |
tRPC growth is primarily at GraphQL's expense in the TypeScript/Next.js space — teams that previously reached for GraphQL for type safety now use tRPC instead.
tRPC: TypeScript-First API Layer
Best for: Next.js full-stack apps, TypeScript monorepos, internal APIs
How It Works
tRPC creates a typed router on the server. The client calls procedures directly — no HTTP method or URL to define, no types to duplicate.
// server/routers/user.ts
import { router, protectedProcedure } from '../trpc';
import { z } from 'zod';
export const userRouter = router({
getProfile: protectedProcedure
.query(async ({ ctx }) => {
return ctx.db.user.findUnique({
where: { id: ctx.session.user.id },
});
}),
updateProfile: protectedProcedure
.input(z.object({
name: z.string().min(1).max(100),
bio: z.string().max(500).optional(),
}))
.mutation(async ({ input, ctx }) => {
return ctx.db.user.update({
where: { id: ctx.session.user.id },
data: input,
});
}),
getUsageStats: protectedProcedure
.input(z.object({ days: z.number().min(1).max(365).default(30) }))
.query(async ({ input, ctx }) => {
const since = new Date();
since.setDate(since.getDate() - input.days);
return ctx.db.event.count({
where: { userId: ctx.session.user.id, createdAt: { gte: since } },
});
}),
});
// server/root.ts — merge all routers
import { router } from './trpc';
import { userRouter } from './routers/user';
import { billingRouter } from './routers/billing';
import { teamRouter } from './routers/team';
export const appRouter = router({
user: userRouter,
billing: billingRouter,
team: teamRouter,
});
export type AppRouter = typeof appRouter;
// app/api/trpc/[trpc]/route.ts — Next.js App Router handler
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
import { appRouter } from '@/server/root';
import { createContext } from '@/server/context';
const handler = (req: Request) =>
fetchRequestHandler({
endpoint: '/api/trpc',
req,
router: appRouter,
createContext,
});
export { handler as GET, handler as POST };
// Client usage — fully typed, no boilerplate:
import { api } from '@/lib/trpc/client';
export function ProfilePage() {
// Return type is inferred from server definition:
const { data: profile } = api.user.getProfile.useQuery();
const updateProfile = api.user.updateProfile.useMutation({
onSuccess: () => toast.success('Profile updated!'),
});
return (
<form onSubmit={(e) => {
e.preventDefault();
updateProfile.mutate({ name: e.currentTarget.name.value });
}}>
<input name="name" defaultValue={profile?.name ?? ''} />
<button type="submit" disabled={updateProfile.isPending}>
{updateProfile.isPending ? 'Saving...' : 'Save'}
</button>
</form>
);
}
tRPC Characteristics
What you get:
- Zero-boilerplate type safety — types flow from server to client automatically
- Built-in input validation with Zod (same schema validates on server)
- React Query integration via
@trpc/react-query - Subscriptions via WebSockets (
@trpc/server/adapters/ws) - Batching: multiple queries sent in one HTTP request
What you give up:
- External clients can't call tRPC easily (non-TypeScript clients)
- Public API documentation requires extra tooling
- Tight coupling — server and client share types via monorepo
REST: The Universal Standard
Best for: public APIs, mobile clients, non-TypeScript teams, standard HTTP semantics
Next.js Route Handlers
// app/api/users/[id]/route.ts
import { auth } from '@/auth';
import { db } from '@/lib/db';
import { z } from 'zod';
import { NextResponse } from 'next/server';
export async function GET(
request: Request,
{ params }: { params: { id: string } }
) {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const user = await db.user.findUnique({
where: { id: params.id },
select: { id: true, name: true, email: true, createdAt: true },
});
if (!user) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
return NextResponse.json(user);
}
const updateUserSchema = z.object({
name: z.string().min(1).max(100).optional(),
bio: z.string().max(500).optional(),
});
export async function PATCH(
request: Request,
{ params }: { params: { id: string } }
) {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
if (session.user.id !== params.id) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const body = await request.json();
const parsed = updateUserSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.flatten() }, { status: 422 });
}
const user = await db.user.update({
where: { id: params.id },
data: parsed.data,
});
return NextResponse.json(user);
}
Adding Types to REST
Without code generation, you type REST manually:
// types/api.ts — manually maintained types
export interface User {
id: string;
name: string;
email: string;
createdAt: string;
}
export interface UpdateUserInput {
name?: string;
bio?: string;
}
// Client — types are not inferred, must be maintained separately:
async function updateUser(id: string, data: UpdateUserInput): Promise<User> {
const res = await fetch(`/api/users/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (!res.ok) throw new Error('Update failed');
return res.json() as Promise<User>;
}
REST with OpenAPI Codegen
For full type safety with REST, use openapi-typescript:
# openapi.yaml
openapi: 3.0.0
paths:
/api/users/{id}:
get:
operationId: getUser
parameters:
- name: id
in: path
required: true
schema:
type: string
responses:
'200':
content:
application/json:
schema:
$ref: '#/components/schemas/User'
npx openapi-typescript openapi.yaml -o types/api.d.ts
This generates types from your spec — but requires you to maintain the spec file, run codegen, and keep both in sync. tRPC eliminates this entirely.
GraphQL: Flexible Query Language
Best for: complex data graphs, multiple client types with different data needs, large teams
// npm install @apollo/server graphql
// app/api/graphql/route.ts
import { ApolloServer } from '@apollo/server';
import { startServerAndCreateNextHandler } from '@as-integrations/next';
import { gql } from 'graphql-tag';
import { db } from '@/lib/db';
const typeDefs = gql`
type User {
id: ID!
name: String!
email: String!
projects: [Project!]!
usageStats(days: Int): UsageStats!
}
type Project {
id: ID!
name: String!
memberCount: Int!
}
type UsageStats {
messageCount: Int!
tokensUsed: Int!
estimatedCost: Float!
}
type Query {
me: User
user(id: ID!): User
}
type Mutation {
updateProfile(name: String, bio: String): User!
}
`;
const resolvers = {
Query: {
me: async (_: unknown, __: unknown, ctx: Context) => {
if (!ctx.userId) throw new Error('Unauthorized');
return ctx.db.user.findUnique({ where: { id: ctx.userId } });
},
user: async (_: unknown, { id }: { id: string }, ctx: Context) => {
return ctx.db.user.findUnique({ where: { id } });
},
},
User: {
// Field resolvers — only fetched when client requests them:
projects: async (user: { id: string }, _: unknown, ctx: Context) => {
return ctx.db.project.findMany({ where: { userId: user.id } });
},
usageStats: async (
user: { id: string },
{ days = 30 }: { days?: number },
ctx: Context
) => {
const since = new Date();
since.setDate(since.getDate() - days);
const result = await ctx.db.aiUsage.aggregate({
where: { userId: user.id, createdAt: { gte: since } },
_count: { id: true },
_sum: { totalTokens: true, estimatedCostUsd: true },
});
return {
messageCount: result._count.id,
tokensUsed: result._sum.totalTokens ?? 0,
estimatedCost: Number(result._sum.estimatedCostUsd ?? 0),
};
},
},
Mutation: {
updateProfile: async (
_: unknown,
input: { name?: string; bio?: string },
ctx: Context
) => {
if (!ctx.userId) throw new Error('Unauthorized');
return ctx.db.user.update({
where: { id: ctx.userId },
data: input,
});
},
},
};
const server = new ApolloServer({ typeDefs, resolvers });
export const GET = startServerAndCreateNextHandler(server, {
context: async (req) => ({ db, userId: getUserIdFromRequest(req) }),
});
export const POST = GET;
// Client with @apollo/client:
import { gql, useQuery } from '@apollo/client';
const GET_ME = gql`
query GetMe {
me {
id
name
# Client chooses exactly what fields to fetch:
usageStats(days: 30) {
messageCount
tokensUsed
}
# Projects only fetched if included in query:
projects {
id
name
}
}
}
`;
export function Dashboard() {
const { data, loading } = useQuery(GET_ME);
if (loading) return <Skeleton />;
return (
<div>
<h1>Welcome, {data.me.name}</h1>
<p>{data.me.usageStats.messageCount} messages this month</p>
</div>
);
}
GraphQL Characteristics
What you get:
- Clients request exactly the fields they need — no over/under-fetching
- Single endpoint for all operations
- Self-documenting via introspection
- Powerful for complex, nested data relationships
What you give up:
- Significant setup: schema, resolvers, Apollo Client config, DataLoader for N+1
- HTTP caching is harder (all requests are POST)
- Bundle size: Apollo Client adds ~30KB gzipped
- Over-engineering risk: most SaaS apps don't need the flexibility
Side-by-Side Comparison
| tRPC | REST | GraphQL | |
|---|---|---|---|
| Type safety | Automatic (no codegen) | Manual or OpenAPI codegen | Codegen from schema |
| Bundle size (client) | ~10KB | 0KB (native fetch) | ~30KB (Apollo) / ~8KB (urql) |
| Learning curve | Low (just TypeScript) | Minimal | High (SDL, resolvers, DataLoader) |
| External clients | Difficult | ✅ Universal | ✅ Universal |
| Public API | ❌ | ✅ | ✅ |
| Caching | Via React Query | HTTP/CDN-friendly | Complex (POST by default) |
| Subscriptions | WebSocket | SSE / WebSocket | WebSocket |
| Boilerplates (T3) | ✅ Default | - | - |
| Boilerplates (ShipFast) | - | ✅ Default | - |
| Complexity ceiling | Simple-Medium | All scales | Complex graphs |
Server Actions vs. All Three
For internal Next.js mutations, Server Actions now compete with all three:
// Server Action — no API layer at all:
'use server';
export async function updateProfile(formData: FormData) {
const session = await auth();
// Runs on server, no HTTP round-trip, no API to define
await db.user.update({ where: { id: session.user.id }, data: {...} });
revalidatePath('/dashboard');
}
Server Actions work well for form mutations. They don't work for:
- Data fetching (queries)
- External client access
- Mobile app consumption
The pattern that's winning in 2026: Server Actions for mutations + tRPC queries for data fetching, in Next.js-only TypeScript apps.
When to Use Each
Choose tRPC if:
→ Full-stack TypeScript Next.js app
→ Internal API (no external consumers)
→ Team wants zero boilerplate
→ Using React Query already
→ T3 Stack or similar monorepo
Choose REST if:
→ Public API that others will consume
→ Mobile app clients (iOS, Android)
→ Non-TypeScript consumers
→ Simple CRUD without complex data needs
→ Team unfamiliar with tRPC
Choose GraphQL if:
→ Multiple client types with different data requirements
→ Complex nested data (social graph, CMS, reporting)
→ Large team where schema acts as contract
→ You're already invested in GraphQL tooling
→ Mobile app that benefits from precise field selection
Compare tRPC, REST, and GraphQL package health scores, download trends, and bundle sizes on PkgPulse.