Skip to main content

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

·PkgPulse Team

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.

Comments

Stay Updated

Get the latest package insights, npm trends, and tooling tips delivered to your inbox.