AverageDevs
System DesignDatabaseArchitecture

A Beginner's Guide to REST vs GraphQL APIs - When to Use Which

A practical, engineering-first comparison of REST and GraphQL for web apps - concepts, performance, caching, pagination, errors, schema evolution, security, real time, and TypeScript examples.

A Beginner's Guide to REST vs GraphQL APIs - When to Use Which

Choosing between REST and GraphQL is less a framework war and more a system design question: What are your data shapes, how fast do they change, and who needs which fields at what latency and cost. This guide is for beginners who know how to build an API route but want to understand the tradeoffs that matter in production. We will keep the discussion pragmatic with TypeScript snippets and small diagrams you can reason about.

If you are building on Next.js, pair this with our deployment and performance guides - Deploy Next.js on a VPS and Next.js SEO Best Practices. For AI features that hit your API often, read Integrate OpenAI API in Next.js.

Quick definitions

  • REST: Resource based HTTP APIs using nouns and verbs. Clients fetch or mutate resources by URL and method.
  • GraphQL: A query language and runtime where clients ask for exactly the fields they need across types in a single request.
REST
  GET /users/42            → { id, name, email, ... }
  GET /users/42/posts      → [{ id, title }, ...]

GraphQL
  POST /graphql
  { user(id: 42) { id name posts(limit: 5) { id title } } }

When REST shines vs when GraphQL shines

REST is great when:

  • You have coarse resources and simple client needs per screen.
  • Caching can be done with standard HTTP semantics and CDNs.
  • Your backend teams are organized by resource ownership.

GraphQL is great when:

  • Clients need flexible field selection and want to avoid over fetching.
  • You have many related resources and want joins at the API layer.
  • Your product teams iterate quickly on UI data needs without waiting for new endpoints.

Modeling resources and types

In REST you model resources and relationships via URLs and sub resources. In GraphQL you model types and fields with a schema.

// REST - Next.js Route Handler
// app/api/users/[id]/route.ts
import { NextRequest, NextResponse } from "next/server";

export const GET = async (_req: NextRequest, { params }: { params: { id: string } }) => {
  const user = await db.user.findUnique({ where: { id: Number(params.id) } });
  if (!user) return NextResponse.json({ error: "Not found" }, { status: 404 });
  return NextResponse.json({ id: user.id, name: user.name, email: user.email });
};
// GraphQL - minimal schema and resolver (Apollo or Yoga style pseudocode)
const typeDefs = /* GraphQL */ `
  type User { id: ID!, name: String!, email: String!, posts(limit: Int): [Post!]! }
  type Post { id: ID!, title: String! }
  type Query { user(id: ID!): User }
`;

const resolvers = {
  Query: {
    user: (_: unknown, args: { id: string }) => db.user.findUnique({ where: { id: Number(args.id) } }),
  },
  User: {
    posts: (user: { id: number }, args: { limit?: number }) =>
      db.post.findMany({ where: { authorId: user.id }, take: args.limit ?? 10 }),
  },
};

Over fetching vs under fetching

  • REST can over fetch - the endpoint returns fields your screen does not use. You can add sparse fieldsets or new endpoints to reduce payload size.
  • GraphQL avoids over fetching by selecting fields, but can under fetch if the query does not include what you need. The fix is client side - update the query.

In practice, payload size matters most on mobile connections. Always measure with your real screens and set budgets.

Pagination patterns

REST common patterns: ?page=2&pageSize=20 or cursor based ?after=opaqueCursor. GraphQL prefers cursor based pagination in the schema.

// REST - cursor pagination response shape
type Page<T> = { items: T[]; nextCursor?: string };

// GraphQL - Relay style connection
type PageInfo = { hasNextPage: boolean; endCursor?: string };
type Connection<T> = { edges: Array<{ node: T; cursor: string }>; pageInfo: PageInfo };

Cursor pagination is more robust for changing datasets and avoids duplicates when items are inserted or deleted.

Caching and performance

REST uses HTTP caching well. CDNs understand Cache-Control, ETags, and can cache by URL.

// REST - cache headers for a list
return new Response(JSON.stringify(data), {
  headers: {
    "Content-Type": "application/json",
    "Cache-Control": "public, max-age=60, s-maxage=300, stale-while-revalidate=60",
    ETag: etagValue,
  },
});

GraphQL caching is trickier because many queries share the same URL. You will cache at the application layer using persisted queries, automatic persisted queries, or normalized client caches like Apollo or URQL. On the server, cache resolver results by key and arguments.

// GraphQL - naive resolver cache key
const key = `user:${args.id}`; // include args for correctness
const cached = await kv.get(key);
if (cached) return cached;
const user = await db.user.findUnique({ where: { id: Number(args.id) } });
await kv.set(key, user, { ex: 300 });
return user;

The N+1 problem and batching

GraphQL resolvers can cause N+1 queries when fetching nested fields in loops. Fix it with batching and caching per request.

// DataLoader style batching per request
import DataLoader from "dataloader";

export const buildPostLoader = () =>
  new DataLoader<number, Post[]>(async (authorIds) => {
    const rows = await db.post.findMany({ where: { authorId: { in: authorIds as number[] } } });
    const byAuthor = new Map<number, Post[]>();
    for (const r of rows) {
      const list = byAuthor.get(r.authorId) ?? [];
      list.push(r);
      byAuthor.set(r.authorId, list);
    }
    return authorIds.map((id) => byAuthor.get(id) ?? []);
  });

You can also avoid N+1 by pre-joining data in your DB queries or using views.

Mutations and errors

REST uses status codes and bodies for errors. GraphQL returns data plus an errors array. Consistency matters - document error codes and shapes.

// REST - error response
return NextResponse.json({ code: "USER_NOT_FOUND", message: "User not found" }, { status: 404 });
// GraphQL - typical error shape
{
  "data": { "user": null },
  "errors": [{ "message": "User not found", "path": ["user"], "extensions": { "code": "USER_NOT_FOUND" } }]
}

In GraphQL, prefer explicit error codes in extensions.code so clients can handle them predictably.

Versioning and schema evolution

REST often versions via the URL like /v2/users or via headers. GraphQL avoids explicit versions by evolving the schema - add fields, deprecate old ones, and remove after a sunset period. Both approaches need a deprecation policy and communication.

Tips:

  • Avoid breaking changes without a migration window.
  • Document deprecations in the schema or OpenAPI.
  • Track usage of deprecated fields with logs or analytics.

Security basics

Both patterns need auth, rate limits, and input validation.

  • REST: validate request bodies with a schema library like zod, enforce auth per route, rate limit by IP or token, and use scopes per method.
  • GraphQL: validate variables against the schema, add depth limits and query cost analysis, rate limit at the operation level, and consider persisted queries to prevent overly complex queries.
// REST - zod validation example
import { z } from "zod";
const CreateUser = z.object({ name: z.string(), email: z.string().email() });

export const POST = async (req: NextRequest) => {
  const json = await req.json();
  const input = CreateUser.parse(json);
  // create user with input
};

Real time

REST usually adds Server Sent Events or WebSockets for live updates. GraphQL has subscriptions for real time typed events.

Client ── SSE ──> /api/stream
Client ── WS  ──> /graphql (subscriptions)

If you do not need bi-directional messaging, prefer SSE for simplicity and CDN friendliness.

Tooling and DX

  • REST: OpenAPI for documentation and client generation. Easy to test with curl and browser.
  • GraphQL: GraphiQL and schema introspection for exploration. Strong typing end to end if you generate types from the schema.

In Next.js, both integrate well. For API routes, see Integrate OpenAI API in Next.js for patterns that carry over to any server handler.

A simple decision guide

Your UI needs are stable and endpoints map cleanly to screens
  → Use REST and embrace HTTP caching

Your UI data needs change often and you aggregate many related types per screen
  → Use GraphQL with a schema you own and batch resolvers

You already have REST and just need a more flexible read API for a few views
  → Add GraphQL as a read facade while keeping writes in REST

Putting it together in Next.js

// app/api/graphql/route.ts - minimal GraphQL endpoint
import { NextRequest } from "next/server";
import { createYoga } from "graphql-yoga";
import { makeExecutableSchema } from "@graphql-tools/schema";

const schema = makeExecutableSchema({ typeDefs, resolvers });
const yoga = createYoga({ schema, graphqlEndpoint: "/api/graphql" });

export const POST = (req: NextRequest) => yoga.handleRequest(req);
export const GET = (req: NextRequest) => yoga.handleRequest(req);
// app/api/users/route.ts - REST list with cache hints
import { NextResponse } from "next/server";

export const GET = async () => {
  const users = await db.user.findMany({ take: 50 });
  return new Response(JSON.stringify(users), {
    headers: { "Content-Type": "application/json", "Cache-Control": "public, s-maxage=120, stale-while-revalidate=60" },
  });
};

With either approach, measure field performance. If you want to compress many signals into a daily brief for non engineers, see AI summarized dashboards.

Common pitfalls

  • GraphQL without query complexity limits can be abused. Add depth and cost controls and prefer persisted queries for public apps.
  • REST that returns extremely nested payloads becomes client heavy. Split responses or move joins server side.
  • In GraphQL, not batching resolvers leads to expensive N+1 patterns. In REST, creating too many fine grained endpoints increases latency due to waterfalls.

Conclusion

REST and GraphQL are both excellent. The best choice aligns with your product shape and team structure. Start with the simplest option that meets your UI needs and caching plan. If your clients keep asking for new slices of data, GraphQL can speed iteration. If your screens map to clear resources and you want maximum CDN leverage, REST is hard to beat.

Where to go next: