GraphQL vs REST vs tRPC: Choosing the Right API in 2026 GraphQL vs REST vs tRPC API architecture comparison 2026

In 2026, the question isn't which API style "won" — it's which one fits your team's constraints, client diversity, and deployment model. tRPC has become the default for TypeScript full-stack teams building with Next.js and a single client. GraphQL holds its ground in large organisations where multiple clients — web, iOS, Android, third-party — need flexible access to the same data graph. REST remains the universal language of the web: boring, reliable, and the right call for any public API that non-TypeScript clients will consume. Here's how to reason through the choice.

REST — Universal, Cacheable, Boring in the Best Way

REST has been the default API style since Roy Fielding described it in 2000, and its staying power comes from simplicity: HTTP verbs, resource-oriented URLs, status codes that every HTTP client understands. In 2026, REST's advantages are largely unchanged but more appreciated:

  • HTTP caching works out of the box. GET requests can be cached by CDNs, browsers, and reverse proxies without any configuration. GraphQL queries are POST requests by default, which HTTP caches don't cache.
  • Universal client compatibility. Curl, Postman, mobile apps, other microservices, third-party integrations — anything that speaks HTTP can consume a REST API without a specialised client library.
  • OpenAPI tooling is mature. FastAPI, Express with swagger-jsdoc, and NestJS all generate interactive API documentation automatically from your code. Enterprise clients expect this.
  • Simpler error handling. HTTP status codes carry semantic meaning. 404 means not found. 403 means forbidden. GraphQL returns 200 for everything and embeds errors in the response body — which breaks conventional monitoring tools.

A clean REST endpoint for fetching an order with its items in Express + TypeScript:

// routes/orders.ts
import { Router, Request, Response } from 'express';
import { z } from 'zod';
import { OrderService } from '../services/OrderService';

const router = Router();

const GetOrderParams = z.object({ orderId: z.string().uuid() });
const GetOrderQuery  = z.object({ includeItems: z.coerce.boolean().default(false) });

router.get('/:orderId', async (req: Request, res: Response) => {
  const params = GetOrderParams.safeParse(req.params);
  const query  = GetOrderQuery.safeParse(req.query);

  if (!params.success) return res.status(400).json({ error: 'Invalid order ID format' });
  if (!query.success)  return res.status(400).json({ error: 'Invalid query parameters' });

  const order = await OrderService.findById(params.data.orderId, {
    includeItems: query.data.includeItems,
  });

  if (!order) return res.status(404).json({ error: 'Order not found' });

  return res.json(order);
});

export default router;

Where REST breaks down: Deeply nested data models create over-fetching — you request a user, then their orders, then each order's items: three round trips. Or you return everything in one endpoint and over-fetch for clients that only need one field. This impedance mismatch is what motivated GraphQL.

GraphQL — Powerful, But You Pay for the Power

GraphQL's key proposition is that clients declare exactly what data they need and receive exactly that — no more, no less. One endpoint, flexible queries, subscriptions for real-time, and a self-documenting schema. In organisations with multiple clients consuming the same data — a web dashboard, a customer mobile app, a partner integration — GraphQL's flexibility pays dividends.

# The same order query in GraphQL — client controls shape
query GetOrder($orderId: ID!, $includeItems: Boolean!) {
  order(id: $orderId) {
    id
    status
    total
    customer {
      name
      email
    }
    items @include(if: $includeItems) {
      productId
      name
      quantity
      unitPrice
    }
  }
}

A server-side resolver using Pothos (the schema-builder that replaced nexus/type-graphql in most new projects):

// schema/order.ts with Pothos
import SchemaBuilder from '@pothos/core';
import PrismaPlugin from '@pothos/plugin-prisma';

const builder = new SchemaBuilder<{
  PrismaTypes: PrismaTypes;
}>({
  plugins: [PrismaPlugin],
  prisma: { client: prisma },
});

builder.queryField('order', (t) =>
  t.prismaField({
    type: 'Order',
    nullable: true,
    args: {
      id: t.arg.id({ required: true }),
    },
    resolve: async (query, _root, args, ctx) => {
      // Authorization check
      if (!ctx.user) throw new GraphQLError('Unauthenticated', {
        extensions: { code: 'UNAUTHENTICATED' },
      });

      return prisma.order.findUnique({
        ...query, // Pothos injects only the fields the client requested
        where: { id: String(args.id) },
      });
    },
  })
);

Where GraphQL breaks down:

  • The N+1 query problem. Without DataLoader, fetching a list of orders and their customers issues one query per order. DataLoader batches these, but it must be set up deliberately.
  • Caching complexity. POST-based queries bypass HTTP caches. Persisted queries and CDN-level GraphQL caching (via Apollo CDN or Cloudflare Workers) work but add operational complexity.
  • Schema maintenance overhead. Every new field requires a resolver. In fast-moving startups, REST endpoints can be shipped faster because there's no schema-first contract to maintain.
  • Authorisation at the field level. REST endpoints can gate access with a single middleware. GraphQL requires field-level authorisation for every sensitive resolver, which is easy to miss and hard to audit.

tRPC — The TypeScript Full-Stack Default

tRPC's central idea is elegant: if your frontend and backend are both TypeScript, you don't need a schema at all. The backend router's type definitions are directly imported by the client through TypeScript's type system. There's no schema file, no code generation step, no SDL to maintain — types flow end-to-end automatically.

// server/router/orders.ts
import { z } from 'zod';
import { router, protectedProcedure } from '../trpc';

export const ordersRouter = router({
  getById: protectedProcedure
    .input(z.object({
      orderId:      z.string().uuid(),
      includeItems: z.boolean().default(false),
    }))
    .query(async ({ input, ctx }) => {
      const order = await ctx.db.order.findUnique({
        where: { id: input.orderId },
        include: { items: input.includeItems },
      });

      if (!order) throw new TRPCError({ code: 'NOT_FOUND' });
      return order;
    }),

  updateStatus: protectedProcedure
    .input(z.object({
      orderId: z.string().uuid(),
      status:  z.enum(['pending', 'processing', 'shipped', 'delivered', 'cancelled']),
    }))
    .mutation(async ({ input, ctx }) => {
      return ctx.db.order.update({
        where: { id: input.orderId },
        data:  { status: input.status, updatedAt: new Date() },
      });
    }),
});

On the client side, the call looks like a regular async function — fully typed, with autocomplete:

'use client';
// components/OrderDetail.tsx
import { trpc } from '@/lib/trpc/client';

export function OrderDetail({ orderId }: { orderId: string }) {
  const { data: order, isLoading } = trpc.orders.getById.useQuery({
    orderId,
    includeItems: true,
  });

  const updateStatus = trpc.orders.updateStatus.useMutation({
    onSuccess: () => {
      // Invalidate and refetch
      trpc.useUtils().orders.getById.invalidate({ orderId });
    },
  });

  if (isLoading) return <OrderSkeleton />;
  if (!order) return <NotFound />;

  return (
    <div>
      <h1>Order #{order.id}</h1>
      <p>Status: {order.status}</p>
      <button
        onClick={() => updateStatus.mutate({ orderId, status: 'shipped' })}
        disabled={updateStatus.isPending}
      >
        Mark as Shipped
      </button>
    </div>
  );
}

Notice what's absent: no import of a generated type file, no schema definition, no separate client configuration beyond the tRPC client setup. If you rename getById to findById in the router, every call site in your frontend immediately shows a TypeScript error. Refactoring across the stack becomes as safe as refactoring within a single file.

Where tRPC breaks down:

  • TypeScript only. Any client that isn't TypeScript — a Swift app, a Python script, a partner's integration — cannot use tRPC natively. There are OpenAPI adapters, but they defeat the purpose.
  • Tighter coupling between frontend and backend. In a tRPC setup, the router type is imported by the client, which means they often live in the same repository. Separate teams working in separate repos need more ceremony to share types.
  • No built-in subscriptions in older versions. tRPC 11 added WebSocket subscriptions, but they're less battle-tested than GraphQL subscriptions at scale.

The Same Query in All Three — Side by Side

To make the difference concrete, here's fetching a user's recent orders in all three styles:

// REST — HTTP GET request
const response = await fetch(`/api/users/${userId}/orders?limit=5&status=recent`);
const { orders } = await response.json(); // type: any

// GraphQL — Apollo Client
const { data } = useQuery(GET_USER_ORDERS, {
  variables: { userId, limit: 5, status: 'recent' },
});
// data.userOrders — typed only if you ran code generation

// tRPC — direct call, fully typed
const { data } = trpc.users.getRecentOrders.useQuery({
  userId,
  limit: 5,
  status: 'recent',
});
// data — typed as the router's return type, automatically

Decision Guide for Indian SaaS Teams

Most product teams building in India in 2026 fit one of these profiles:

Choose tRPC if: Your team is TypeScript-only, you're building with Next.js, your only client is a web app or React Native app, and you want maximum developer velocity without maintaining a schema. This is the t3-app stack (Next.js + tRPC + Prisma + Tailwind) and it's the right call for most early-stage SaaS products where the entire team ships both frontend and backend.

Choose GraphQL if: You have multiple clients with meaningfully different data needs (a mobile app that needs minimal data, a web dashboard that needs rich joins), you have a data graph that genuinely benefits from flexible querying, or your enterprise clients expect a schema they can explore with GraphiQL or Postman. GitHub, Shopify, and most large developer platforms use GraphQL for these reasons.

Choose REST if: You're building a public API that third parties will integrate with, you need aggressive HTTP caching at the CDN level, your team is polyglot (some Python, some Node, some Go), or you need enterprise-grade OpenAPI documentation for client onboarding. REST is also the safest starting point — you can always add a tRPC or GraphQL layer later, but migrating an existing REST API to GraphQL is painful.

The hybrid approach is increasingly common and makes sense: use tRPC for your internal Next.js dashboard, use REST for your public API that partners consume, and keep the option open for GraphQL if you accumulate enough clients with divergent data needs. These paradigms are not mutually exclusive and can coexist within the same backend codebase.

Setting Up tRPC with Next.js App Router

The t3-app scaffold handles this, but here's the core setup manually for Next.js 15:

// lib/trpc/server.ts — server-side caller
import { createCallerFactory, createTRPCRouter } from '@/server/trpc';
import { ordersRouter } from '@/server/router/orders';
import { usersRouter } from '@/server/router/users';

export const appRouter = createTRPCRouter({
  orders: ordersRouter,
  users:  usersRouter,
});

export type AppRouter = typeof appRouter;

// lib/trpc/client.ts — React Query client
import { createTRPCReact } from '@trpc/react-query';
import type { AppRouter } from './server';

export const trpc = createTRPCReact<AppRouter>();

// app/api/trpc/[trpc]/route.ts — HTTP handler
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
import { appRouter } from '@/lib/trpc/server';
import { createContext } from '@/server/context';

const handler = (req: Request) =>
  fetchRequestHandler({
    endpoint: '/api/trpc',
    req,
    router: appRouter,
    createContext,
  });

export { handler as GET, handler as POST };

Frequently Asked Questions

Can tRPC be used with a non-TypeScript client, like a React Native app or a mobile app?

tRPC's type-sharing mechanism requires TypeScript on both ends — the types flow from the server router definition to the client via TypeScript's type system, with no runtime contract (no JSON Schema, no SDL). A React Native app written in TypeScript can use tRPC directly. However, a Swift iOS app or a Kotlin Android app cannot consume a tRPC API natively because there's no schema to generate a client from. For projects that need non-TypeScript clients, REST with OpenAPI or GraphQL with code generation are the appropriate choices.

How does GraphQL handle the N+1 query problem, and is it solved in 2026?

The N+1 problem occurs when resolving a list: fetching 100 posts and then making 100 separate database calls to fetch each post's author. The standard solution is DataLoader — a batching utility that collects all author ID lookups within a single event loop tick and issues one query. DataLoader is well-established and works with any GraphQL server. In 2026, most production GraphQL setups use DataLoader by default, and frameworks like Pothos and GraphQL Yoga have built-in batching support. It's a solved problem, but it requires deliberate implementation rather than being automatic.

For an Indian SaaS product targeting both B2B enterprise clients and individual consumers, which API approach is safest?

Start with REST for your public-facing API — it's universally understood, works with any client, and integrates cleanly with API gateways, rate limiters, and documentation tools like Swagger UI. Use tRPC internally for your Next.js dashboard and admin panels where TypeScript is on both ends. This hybrid approach is common: REST for the external contract that clients and partners depend on, tRPC for the internal interface where developer speed matters. GraphQL becomes worth the investment once you acquire enterprise clients who need flexible data querying across large datasets or multiple client surfaces.