All posts
graphqlapi

GraphQL APIs: A Practical Guide for Full-Stack Developers

A practical guide to building GraphQL APIs — schema design, resolvers, the N+1 problem, and when REST is still the better choice.

SR

Suhail Roushan

August 6, 2026

·
5 min read
·
0 views

GraphQL APIs let clients request exactly the fields they need in a single round trip, and the tradeoff nobody mentions in the pitch is that "exactly what you need" shifts real complexity from the client onto your server's resolver graph.

A GraphQL API exposes a single endpoint with a strongly-typed schema, where clients send queries describing the exact shape of data they want, and the server resolves each field through a graph of resolver functions. Unlike REST, where each endpoint returns a fixed shape, GraphQL lets a mobile client fetch a lean subset of fields while a dashboard client fetches a rich, nested one — from the same schema, same endpoint.

Why GraphQL Matters (and When to Skip It)

GraphQL solves two real REST pain points: over-fetching (getting a full user object when you only needed the name) and under-fetching (needing three separate REST calls to assemble one screen's data). For apps with many different clients hitting overlapping data — a mobile app, a web app, a partner API — a shared GraphQL schema avoids maintaining N different REST endpoint shapes for the same underlying data.

Skip GraphQL for simple CRUD APIs with a single client type, where REST's simplicity, cacheability (via HTTP caching), and lower operational complexity outweigh GraphQL's flexibility. GraphQL also adds real complexity around caching, rate limiting, and query cost analysis that REST gets closer to free.

Getting Started with a GraphQL API

A minimal schema and resolver using Apollo Server:

import { ApolloServer } from "@apollo/server";
import { startStandaloneServer } from "@apollo/server/standalone";

const typeDefs = `#graphql
  type User {
    id: ID!
    name: String!
    posts: [Post!]!
  }

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

  type Query {
    user(id: ID!): User
  }
`;

const resolvers = {
  Query: {
    user: (_: unknown, args: { id: string }) => db.users.findById(args.id),
  },
  User: {
    posts: (parent: { id: string }) => db.posts.findByUserId(parent.id),
  },
};

const server = new ApolloServer({ typeDefs, resolvers });
await startStandaloneServer(server, { listen: { port: 4000 } });

A client can now request { user(id: "1") { name posts { title } } } and get exactly that shape back — nothing more.

Core GraphQL Concepts Every Developer Should Know

Resolvers execute per-field, not per-request. Each field in the schema (User.posts, User.name) has its own resolver function, called only if the client actually requested that field — this is the mechanism behind avoiding over-fetching.

The N+1 problem is GraphQL's most common performance trap. Querying 50 users and then each user's posts naively triggers 1 query for users plus 50 separate queries for posts — one per user. Fix with a DataLoader, which batches and deduplicates those calls into a single query:

import DataLoader from "dataloader";

const postsByUserLoader = new DataLoader(async (userIds: readonly string[]) => {
  const posts = await db.posts.findByUserIds(userIds as string[]);
  return userIds.map((id) => posts.filter((p) => p.userId === id));
});

// in resolver
posts: (parent: { id: string }) => postsByUserLoader.load(parent.id),

Mutations follow the same resolver pattern as queries, but by convention take an input object and return the modified resource:

type Mutation {
  createPost(input: CreatePostInput!): Post!
}

Schema-first design keeps frontend and backend in sync. Tools like GraphQL Code Generator produce fully typed client hooks directly from the schema, closing the same type-safety gap tRPC and Hono's RPC client solve for REST-style APIs.

Common GraphQL Mistakes and How to Fix Them

Mistake 1: no query depth or complexity limiting. A deeply nested query (user { posts { comments { author { posts { ... } } } } }) can be used to construct expensive or even denial-of-service-scale requests. Fix: use a query complexity analysis library and cap maximum query depth.

Mistake 2: ignoring the N+1 problem until it's a production incident. This is the single most common GraphQL performance bug, and it's invisible in development with small datasets. Fix: add DataLoader (or your ORM's batching equivalent) for any resolver that fetches related data, from the start.

Mistake 3: treating every REST endpoint as a 1:1 GraphQL field. This just recreates REST's rigidity inside GraphQL's syntax. Fix: design the schema around how clients actually need to traverse relationships, not around your existing REST endpoint list.

When Should You Use GraphQL Instead of REST?

Use GraphQL when multiple client types need different slices of overlapping data, or when frontend teams need to iterate on data requirements without backend deploys for every new field combination. Use REST for simpler APIs, public APIs where HTTP caching matters, or services with a single well-defined client.

GraphQL APIs in Production

Set up persisted queries in production — clients send a query hash instead of the full query string, which both improves performance and closes the "arbitrary query complexity from untrusted clients" security concern. Also invest in query complexity limits and per-field rate limiting early; GraphQL's flexibility is exactly what makes it more exposed to abusive queries than a fixed REST endpoint.

Before adopting GraphQL for a new API, count your actual client types — if there's only one, REST will get you there with a fraction of the operational complexity.

Related posts

Written by Suhail Roushan — Full-stack developer. More posts on AI, Next.js, and building products at suhailroushan.com/blog.

Get in touch