REST vs GraphQL:
the complete build guide

Shivam Thakur 14 min read
Side-by-side diagram of a REST endpoint and a GraphQL query

So you want to build an API. Cool. But then someone in your team chat drops “should we just use GraphQL?” and suddenly there’s a 45-message thread, three people arguing about “over-fetching,” and someone linked a decade-old blog post like it’s scripture. Take a breath. Grab your coffee — we’re going to walk through this together, like I’m sitting next to you with a marker at the whiteboard, explaining why your friend’s startup picked GraphQL and why your bank’s API will probably never touch it.

The restaurant analogy (why this whole debate exists)

Imagine two restaurants. Restaurant REST has a menu with fixed combos — you order “Combo #3” and get everything in it, rice, dal, sabzi, roti, even if you only wanted the dal. Want just the dal? Order it separately, on another trip to the counter. Restaurant GraphQL hands you a blank order sheet instead. You write exactly what you want — “just dal, extra roti, no rice, and a lassi” — one order, one trip, exactly what you asked for. Nothing more, nothing less.

That one analogy explains most of the REST vs GraphQL debate. Keep it in your back pocket — we’ll come back to it.

Illustration comparing a fixed combo meal to a custom order sheet, representing REST vs GraphQL
Restaurant REST serves fixed combos; Restaurant GraphQL takes a custom order sheet.

What REST actually is

REST stands for Representational State Transfer — a fancy name for a simple idea, defined by Roy Fielding in his year-2000 PhD thesis (yes, REST is old enough to have a driver’s license). The core idea: everything is a resource, and you interact with resources using standard HTTP methods.

Think of your app’s data as nouns — a user is /users, a specific user is /users/42, that user’s orders are /users/42/orders. HTTP verbs are the actions you take on those nouns:

HTTP methodWhat it doesPlain-English version
GETRead data“Show me”
POSTCreate new data“Make a new one”
PUTReplace data entirely“Replace the whole thing”
PATCHUpdate part of the data“Just tweak this field”
DELETERemove data“Get rid of it”

So GET /users/42 means “show me user 42.” DELETE /users/42 means “delete user 42.” Simple, predictable, and exactly why REST took over the internet for two decades.

The rules that make an API “RESTful”

Not every JSON-over-HTTP API is truly RESTful. To earn the name, an API should follow:

  1. Client–server separation — frontend and backend only need to agree on the contract, not each other’s internals.
  2. Statelessness — every request carries everything needed to process it; the server doesn’t remember you between calls.
  3. Cacheability — responses should indicate whether they can be cached, saving everyone bandwidth.
  4. Uniform interface — consistent naming and structure across every endpoint.
  5. Layered system — the client shouldn’t need to know if it’s talking to the server directly or through a proxy or gateway.
Diagram of a client sending an HTTP request through a server to a database
The REST flow: client sends an HTTP request, the server processes it against the database, and a response comes back.

Building a real REST API, step by step

Let’s actually build one — a simple Blog Posts API using Node.js and Express, the friendliest combo for beginners.

Step 1: Set up your project

mkdir rest-blog-api && cd rest-blog-api
npm init -y
npm install express

Step 2: Design your resources first

This is the step beginners skip and regret later. Sketch your endpoints before writing code:

GET    /posts          → get all posts
GET    /posts/:id      → get one post
POST   /posts          → create a post
PATCH  /posts/:id      → update a post
DELETE /posts/:id      → delete a post

Step 3: Write the server

// server.js
const express = require('express');
const app = express();
app.use(express.json());

let posts = [
  { id: 1, title: "Hello World", content: "My first post!" }
];

// GET all posts
app.get('/posts', (req, res) => {
  res.json(posts);
});

// GET single post
app.get('/posts/:id', (req, res) => {
  const post = posts.find(p => p.id === parseInt(req.params.id));
  if (!post) return res.status(404).json({ error: "Post not found" });
  res.json(post);
});

// CREATE a post
app.post('/posts', (req, res) => {
  const newPost = { id: posts.length + 1, ...req.body };
  posts.push(newPost);
  res.status(201).json(newPost);
});

// UPDATE a post
app.patch('/posts/:id', (req, res) => {
  const post = posts.find(p => p.id === parseInt(req.params.id));
  if (!post) return res.status(404).json({ error: "Post not found" });
  Object.assign(post, req.body);
  res.json(post);
});

// DELETE a post
app.delete('/posts/:id', (req, res) => {
  posts = posts.filter(p => p.id !== parseInt(req.params.id));
  res.status(204).send();
});

app.listen(3000, () => console.log("REST API running on port 3000"));

Run it with node server.js, and you’ve got a working REST API.

Postman showing a GET /posts request with the JSON response
Testing GET /posts in Postman — the JSON response comes back exactly as designed.

Step 4: Status codes actually matter

This is where a lot of “REST” APIs quietly fail — don’t just return 200 for everything:

CodeMeaningWhen to use it
200 OKSuccessSuccessful GET/PATCH
201 CreatedNew resource madeSuccessful POST
204 No ContentSuccess, nothing to returnSuccessful DELETE
400 Bad RequestClient sent garbageMissing/invalid fields
401 UnauthorizedNot logged inMissing/invalid auth token
403 ForbiddenLogged in, not allowedPermission issue
404 Not FoundResource doesn’t existWrong ID
500 Internal Server ErrorSomething broke server-sideUnhandled exception

A friend once shipped an API that returned 200 OK even on failure, with the error tucked inside the JSON body. Debugging that was a nightmare — don’t be that friend.

Step 5: Add authentication

The industry standard today is JWT (JSON Web Tokens). A rough flow looks like this:

const jwt = require('jsonwebtoken');
const SECRET = "your-secret-key";

// Login route generates a token
app.post('/login', (req, res) => {
  // ... validate username/password ...
  const token = jwt.sign({ userId: 1 }, SECRET, { expiresIn: '1h' });
  res.json({ token });
});

// Middleware to protect routes
function authenticate(req, res, next) {
  const token = req.headers.authorization?.split(' ')[1];
  if (!token) return res.status(401).json({ error: "No token provided" });
  try {
    req.user = jwt.verify(token, SECRET);
    next();
  } catch {
    res.status(401).json({ error: "Invalid token" });
  }
}

// Protected route
app.delete('/posts/:id', authenticate, (req, res) => {
  // only reachable with a valid token
});

Step 6: Versioning

Never break existing users. Version your API from day one:

/api/v1/posts
/api/v2/posts

When you make breaking changes, ship v2 and give people time to migrate off v1.

Diagram showing API version 1 and version 2 running side by side
Versioning in practice: v1 and v2 run side by side, so existing clients never break.
Pick the style that fits the shape of your data and the number of clients — not the one that’s trending this year.

What GraphQL actually is

GraphQL was built by Facebook in 2012 (released publicly in 2015) because their mobile app was making a dozen REST calls just to render one screen, pulling way more data than needed on slow mobile networks. Sound familiar? That’s the “over-fetching, under-fetching” problem.

Over-fetching: GET /users/42 returns the user’s name, email, address, bio, join date, and last login — but your app only needed the name and photo. You pay the bandwidth cost for data you’ll never use. Under-fetching: you need a user’s name AND their last five orders — that’s two separate calls for one screen.

GraphQL fixes both with one core idea: the client asks for exactly the shape of data it wants, in a single request.

Here’s what a GraphQL query actually looks like:

query {
  user(id: 42) {
    name
    profilePicture
    orders(limit: 5) {
      id
      total
      status
    }
  }
}

And the response mirrors exactly that shape:

{
  "data": {
    "user": {
      "name": "Riya Sharma",
      "profilePicture": "riya.jpg",
      "orders": [
        { "id": 101, "total": 599, "status": "delivered" },
        { "id": 102, "total": 250, "status": "pending" }
      ]
    }
  }
}

No extra fields, no extra calls — one request, exact shape. That’s the whole trick.

Building a real GraphQL API, step by step

Let’s build the same Blog Posts API, this time in GraphQL, using Apollo Server.

Step 1: Set up your project

mkdir graphql-blog-api && cd graphql-blog-api
npm init -y
npm install @apollo/server graphql

Step 2: Define your schema

Before writing any logic, you write a schema — a contract of exactly what data and operations exist. This alone eliminates a huge amount of miscommunication between frontend and backend teams.

type Post {
  id: ID!
  title: String!
  content: String!
}

type Query {
  posts: [Post]
  post(id: ID!): Post
}

type Mutation {
  createPost(title: String!, content: String!): Post
  updatePost(id: ID!, title: String, content: String): Post
  deletePost(id: ID!): Boolean
}

Notice: REST bakes GET/POST/PATCH/DELETE into HTTP itself. GraphQL splits everything into just two buckets — Query (reading, like GET) and Mutation (changing data, like POST/PATCH/DELETE combined).

Step 3: Write your resolvers

Resolvers are just functions that know how to fetch or change data for each field in your schema.

// server.js
const { ApolloServer } = require('@apollo/server');
const { startStandaloneServer } = require('@apollo/server/standalone');

let posts = [
  { id: "1", title: "Hello World", content: "My first post!" }
];

const typeDefs = `#graphql
  type Post {
    id: ID!
    title: String!
    content: String!
  }

  type Query {
    posts: [Post]
    post(id: ID!): Post
  }

  type Mutation {
    createPost(title: String!, content: String!): Post
    updatePost(id: ID!, title: String, content: String): Post
    deletePost(id: ID!): Boolean
  }
`;

const resolvers = {
  Query: {
    posts: () => posts,
    post: (_, { id }) => posts.find(p => p.id === id),
  },
  Mutation: {
    createPost: (_, { title, content }) => {
      const newPost = { id: String(posts.length + 1), title, content };
      posts.push(newPost);
      return newPost;
    },
    updatePost: (_, { id, title, content }) => {
      const post = posts.find(p => p.id === id);
      if (!post) return null;
      if (title) post.title = title;
      if (content) post.content = content;
      return post;
    },
    deletePost: (_, { id }) => {
      const exists = posts.some(p => p.id === id);
      posts = posts.filter(p => p.id !== id);
      return exists;
    },
  },
};

const server = new ApolloServer({ typeDefs, resolvers });

startStandaloneServer(server, { listen: { port: 4000 } }).then(({ url }) => {
  console.log(`GraphQL API running at ${url}`);
});

Run it, and Apollo gives you a built-in playground at http://localhost:4000 to test queries visually.

Step 4: Try some queries

Get all posts, but only titles — no content:

query {
  posts {
    id
    title
  }
}

Create a new post:

mutation {
  createPost(title: "GraphQL is fun", content: "Seriously, it is.") {
    id
    title
  }
}

See how the client controls the shape every single time? That’s the whole philosophy.

Step 5: The N+1 problem

Here’s the thing nobody warns beginners about. Say you query:

query {
  posts {
    title
    author {
      name
    }
  }
}

With 100 posts, a naive resolver runs one query to get the posts, then 100 separate queries to fetch each author. That’s the dreaded N+1 problem, and it will quietly wreck your database performance. The fix is a library called DataLoader, which batches and caches those lookups into one efficient query:

const DataLoader = require('dataloader');

const authorLoader = new DataLoader(async (authorIds) => {
  const authors = await db.getAuthorsByIds(authorIds);
  return authorIds.map(id => authors.find(a => a.id === id));
});

Every serious GraphQL API uses this pattern. Learn it early, thank yourself later.

REST vs GraphQL — the honest comparison

RESTGraphQL
Data fetchingFixed shape per endpointClient defines exact shape
Number of endpointsMany (one per resource)Usually just one (/graphql)
Over/under-fetchingCommon problemSolved by design
CachingEasy (HTTP caching, CDNs)Harder (needs custom setup)
File uploadsNative and simpleNeeds extra libraries/specs
Learning curveGentle, widely knownSteeper (schemas, resolvers)
VersioningNeeds /v1, /v2, etc.Usually versionless — add fields instead
Tooling maturityExtremely mature, huge ecosystemMature, but younger
Best forPublic APIs, simple CRUD apps, caching-heavy use casesComplex frontends, mobile apps, deeply related data

When to actually pick REST

  • You’re building a public API that many third parties will consume — REST is more universally understood.
  • HTTP caching at the CDN level is critical for performance.
  • Your data model is simple, without deeply nested relationships.
  • Your team is more comfortable with it and time-to-market matters.

When to actually pick GraphQL

  • You have multiple clients (web, iOS, Android) each needing different slices of the same data.
  • Your screens need deeply nested, related data — think dashboards or social feeds.
  • You’re on mobile or low-bandwidth and over-fetching genuinely costs you.
  • Your frontend team wants independence from backend release cycles.

Real talk: a lot of companies use both — GraphQL as a layer in front of internal REST microservices is a very common pattern. It’s literally what GraphQL was invented to solve at Facebook.

Common mistakes with both

REST mistakes

  1. Not using proper HTTP status codes — returning 200 for errors makes debugging painful.
  2. Verbs in URLs/getUser instead of GET /user. Resources are nouns; methods are verbs.
  3. No paginationGET /posts returning 100,000 rows will eventually break your app. Always paginate.
  4. Ignoring HATEOAS — not mandatory, but including links to related actions genuinely helps discoverability.
  5. Deeply nested URLs/users/42/posts/12/comments/5/replies/3 gets absurd fast. Flatten when you can.

GraphQL mistakes

  1. Ignoring the N+1 problem until production traffic exposes it painfully.
  2. No query depth limiting — a careless or malicious client can crash your server with a deeply nested query. Always cap depth and complexity.
  3. Treating every field as always-fetched — the whole point is selective fetching; don’t defeat that with resolvers that do heavy work regardless of what’s requested.
  4. Skipping schema documentation — your schema is self-documenting if you add descriptions to types and fields. Use it.
  5. No error handling strategy — GraphQL returns 200 OK even on errors (they live in an errors array), which trips up developers used to REST status codes.

Tools worth bookmarking

For REST: Postman, Insomnia or Thunder Client for testing endpoints; Swagger/OpenAPI for documenting and auto-generating client SDKs; Express, FastAPI, Django REST Framework or Spring Boot as popular frameworks.

For GraphQL: Apollo Server/Client as the most popular stack; GraphQL Yoga as a lighter alternative; Postman (it supports GraphQL now); GraphiQL or Apollo Sandbox for built-in query playgrounds; DataLoader for solving N+1; Hasura or PostGraphile to auto-generate a GraphQL API straight from your database.

Wrapping this up

Here’s the truth nobody tells you upfront: REST and GraphQL aren’t rivals fighting to the death — they’re two tools solving two flavors of the same problem. REST is the reliable friend who’s been around forever and rarely surprises you. GraphQL is the newer friend who’s incredibly flexible, but asks a bit more of you upfront — schema design, N+1 awareness, query complexity limits.

If you’re a beginner, start with REST. It teaches HTTP fundamentals that make you a better engineer no matter what you build later. If you’re already experienced and picking for a real project, go back to the restaurant analogy: does your client need the whole combo meal, or a custom order? That question alone gets you to the right answer nine times out of ten.

Now go build something. 😄

FAQ
Let’s build

Designing a new API?
Let’s get it right.