REST vs GraphQL: Choosing the Right API Design

Photo by Unsplash

Photo by Unsplash
REST API design and GraphQL are two dominant approaches to building APIs, and the choice between them shapes how your frontend and backend teams collaborate, how your clients fetch data, and how your API evolves over time. Both are mature, battle-tested approaches with real trade-offs. This post gives you concrete comparisons — same data model, both approaches — so you can make an informed choice for your next project.
REST organizes your API around resources — nouns like /users, /posts, /orders — and uses HTTP verbs (GET, POST, PUT, DELETE) to express intent. GraphQL exposes a single endpoint and lets clients describe exactly the data they need using a query language. The fundamental trade-off is this: REST is simple to understand and cache at the HTTP layer, while GraphQL eliminates over-fetching and under-fetching by giving clients precise control over data shape.
REST's biggest challenge in rich UI applications is that a single screen often requires multiple resources. A blog post page needs the post, the author, the comments, and the tags — that's four round-trips. Each round-trip adds latency, especially on mobile. The response for each resource also includes every field the server knows about, even if the client only needs two of them. GraphQL was designed specifically to solve these problems.
// REST: multiple round-trips to assemble a blog post page
GET /api/posts/42 // fetch post
GET /api/users/7 // fetch author
GET /api/posts/42/comments // fetch comments
GET /api/tags?postId=42 // fetch tags
// GraphQL: single query, exactly the fields you need
query GetPostPage($postId: ID!) {
post(id: $postId) {
title
body
publishedAt
author {
name
avatarUrl
}
comments(first: 5) {
text
author { name }
}
tags { name }
}
}REST over-fetching can be partially solved with sparse fieldsets (?fields=id,title,author) and compound documents following the JSON:API specification. This doesn't eliminate all round-trips but significantly reduces payload size for REST-based mobile APIs.
A well-designed REST API follows a consistent URL structure, uses appropriate HTTP status codes, returns meaningful error bodies, and versions thoughtfully. In Next.js App Router, each route handler maps cleanly to a resource endpoint. TypeScript ensures your response shapes are consistent, and Zod gives you runtime validation of request bodies.
Use plural nouns for collections (/posts, not /post). Use nested resources sparingly — /posts/42/comments is fine, but /users/7/posts/42/comments/5/likes is a smell. Return 201 Created with a Location header for POST requests. Return 204 No Content for successful DELETE. Use 422 Unprocessable Entity for validation errors with a structured error body. Consistent conventions reduce the cognitive load for API consumers.
// REST API with TypeScript (Next.js App Router)
// app/api/posts/[id]/route.ts
export async function GET(req: Request, { params }: { params: { id: string } }) {
const post = await db.post.findUnique({
where: { id: params.id },
include: { author: true, tags: true },
});
if (!post) return Response.json({ error: "Not found" }, { status: 404 });
return Response.json(post);
}
export async function PATCH(req: Request, { params }: { params: { id: string } }) {
const body = await req.json();
const updated = await db.post.update({ where: { id: params.id }, data: body });
return Response.json(updated);
}
export async function DELETE(_: Request, { params }: { params: { id: string } }) {
await db.post.delete({ where: { id: params.id } });
return new Response(null, { status: 204 });
}Versioning via URL path (/api/v1/posts) is simple and explicit, making it easy to run two versions simultaneously. Versioning via Accept header (Accept: application/vnd.api.v2+json) is more RESTful but harder to test in a browser. Versioning via query string (?version=2) is the least intrusive. For internal APIs between your own services, consider using additive-only evolution (never remove fields) instead of versioning at all.
GraphQL solves the over-fetching and under-fetching problems elegantly, but it introduces its own complexity. You need to think about authorization at the field level, not just the resource level. You need a DataLoader to batch database queries and prevent N+1 problems in your resolvers. Caching is more complex because every query is a POST to the same endpoint, making HTTP-level caching ineffective.
When resolving a list of posts with their authors, a naive GraphQL resolver makes one database query per post to fetch the author — the classic N+1 problem. DataLoader solves this by collecting all author IDs during a single event-loop tick and issuing a single batched query. This is essential for any GraphQL API serving more than trivial data volumes.
GraphQL's flexibility can lead to unbounded queries if you don't implement query depth limiting and complexity analysis. A malicious or careless client can craft a deeply nested query that triggers thousands of database calls. Libraries like graphql-depth-limit and graphql-query-complexity help, but you must actively configure them. A REST API with a fixed response shape has a naturally bounded performance profile.
In REST, authorization is typically at the resource level — a middleware checks if the user can access /posts/42. In GraphQL, a single query can touch multiple resource types in one request, so authorization must happen at the resolver level for each field. Libraries like graphql-shield provide a declarative rules-based system for field-level authorization that's composable and testable.
Choose REST for public APIs where you want HTTP caching, broad tooling support, and a predictable access pattern. REST shines for resource-centric operations (CRUD), webhooks, and file uploads. Choose GraphQL when you have multiple clients (web, mobile, third-party) with different data needs, when your data is highly relational, or when your frontend teams need the flexibility to iterate on data requirements without backend changes.
REST is the right choice for: public APIs (better ecosystem for client generation and documentation with OpenAPI/Swagger), file uploads and binary data, simple CRUD services where a resource maps directly to a database table, when HTTP caching at CDN level is critical for performance, and when your consumers are third-party developers unfamiliar with GraphQL.
GraphQL pays the most dividends when: you have a BFF (backend for frontend) pattern with multiple UI clients, your data model is a graph (social networks, knowledge graphs), your frontend teams need to iterate on data requirements without waiting for backend changes, you want a single typed schema as the contract between frontend and backend, or you're building a developer platform where flexibility and exploration are valued.
The two aren't mutually exclusive. Many large-scale systems use REST for simple resource CRUD and file operations, while exposing a GraphQL endpoint for complex, relational data fetching by their primary UI clients.
Regardless of whether you choose REST or GraphQL, some principles apply universally: use consistent naming conventions, design for the client's needs (not the database schema), version thoughtfully, return meaningful errors with actionable messages, document exhaustively, and treat your API as a product that has consumers who depend on stability.
Key API design concepts covered in this post include REST, GraphQL, over-fetching, under-fetching, N+1 problem, and DataLoader.