Skip to main content

tRPC vs REST vs GraphQL: Type-Safe APIs in Next.js 2026

·PkgPulse Team
0

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

PackageWeekly DownloadsTrend
@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

tRPCRESTGraphQL
Type safetyAutomatic (no codegen)Manual or OpenAPI codegenCodegen from schema
Bundle size (client)~10KB0KB (native fetch)~30KB (Apollo) / ~8KB (urql)
Learning curveLow (just TypeScript)MinimalHigh (SDL, resolvers, DataLoader)
External clientsDifficult✅ Universal✅ Universal
Public API
CachingVia React QueryHTTP/CDN-friendlyComplex (POST by default)
SubscriptionsWebSocketSSE / WebSocketWebSocket
Boilerplates (T3)✅ Default--
Boilerplates (ShipFast)-✅ Default-
Complexity ceilingSimple-MediumAll scalesComplex 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.

The 2026 JavaScript Stack Cheatsheet

One PDF: the best package for every category (ORMs, bundlers, auth, testing, state management). Used by 500+ devs. Free, updated monthly.