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.
Production Considerations and Deployment Patterns
Moving tRPC to production surfaces a few architectural decisions that teams often overlook during development. The most important is how you handle the monorepo constraint: tRPC requires sharing types across server and client, which means both must live in the same repository or publish a types package. Teams that separate frontend and backend repositories need to publish an @your-org/api-types package or use a shared types approach — this adds a CI step but is entirely manageable. Batching is enabled by default, which means multiple useQuery calls on one render cycle are sent as a single HTTP request; this is a performance win but can complicate debugging and rate-limiting middleware. For public-facing APIs alongside a tRPC backend, the recommended pattern in 2026 is running both: tRPC for the frontend application, a separate REST endpoint (or tRPC HTTP adapter with openapi-zod-client) for external consumers.
Security and Authentication Middleware
tRPC's middleware system handles authentication elegantly, but the specifics matter for production security. The protectedProcedure pattern should validate session tokens on every request — not once at startup. Context creation runs on every request, so createContext should be lightweight and use request-scoped database connections rather than a shared pool. Input validation through Zod runs before your resolver, meaning malformed requests never reach database code — a significant security benefit over manually validating route handler bodies. For multi-tenant applications, the context should include organizationId and procedures should enforce tenant isolation explicitly. Rate limiting belongs in middleware too: adding a Redis-backed rate limiter to your authedProcedure chain ensures it applies consistently across all protected routes without touching individual resolver logic.
GraphQL's Remaining Strongholds
Despite tRPC's growth, GraphQL has specific scenarios where it remains the better architectural choice in 2026. Mobile apps with diverse screen sizes genuinely benefit from field-level selection — a phone fetching a list view needs far less data than a desktop dashboard rendering the same entities. Public APIs consumed by third parties cannot use tRPC without TypeScript clients, making REST or GraphQL the only viable options. Content APIs in CMS platforms like Contentful, Hygraph, and DatoCMS ship GraphQL because content editors need introspectable, self-documenting APIs that integrate with any frontend framework. Large organizations where the backend team ships a contract to multiple frontend teams also benefit from GraphQL's schema-as-contract model, which enforces that breaking changes are detected before deployment via schema diff tools.
TypeScript Integration and Code Generation
The key differentiator in 2026 is where type safety is established. With tRPC, types are inferred end-to-end from the router definition with no generation step — a TypeScript change on the server immediately surfaces as an error on the client. With GraphQL and the graphql-codegen toolchain, you generate TypeScript types from the SDL schema and regenerate after every schema change; this adds a step but works with any language on the server side. REST's OpenAPI + openapi-typescript generates types from the spec file, but the spec must be kept in sync manually unless you use a runtime-first approach like Zod OpenAPI or tRPC's openapi adapter. For teams making the jump from REST to tRPC, the migration can be incremental: keep existing REST routes, add tRPC for new features, and gradually port stable routes as the team grows comfortable with the patterns.
Performance Nuances and Bundle Optimization
tRPC's React Query integration handles caching, background refetching, and stale-while-revalidate automatically — the same cache invalidation patterns that REST teams configure manually. GraphQL's Apollo Client has a normalized cache that deduplicates entities across queries, which can eliminate redundant network requests in complex UIs but requires understanding cache key configuration. For bundle size, tRPC ships approximately 10KB of client code versus Apollo Client's 30KB+ — significant for mobile-first applications targeting slower networks. Server-side rendering is cleanest with tRPC's createCaller pattern, which calls procedures directly without HTTP overhead, making RSC + tRPC a natural fit for Next.js applications that pre-fetch data at the server boundary before streaming to the client.
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.
See also: Next.js vs Remix and Next.js vs Nuxt.js, From REST to tRPC: The Type-Safe API Revolution.