Building a GraphQL API with Apollo: Schema, Resolvers, Mutations, Subscriptions and React Client

Key takeaways

A practical guide to building efficient APIs with GraphQL—defining schemas, resolvers, queries, mutations, and subscriptions, plus Apollo Server and Apollo Client examples in TypeScript.

What this post is about

This is a guide to building efficient APIs with GraphQL. It covers schema definition, resolvers, queries, mutations, subscriptions, and Apollo Server/Client—with runnable examples.

From experience: the most tangible win when a mobile client moves from a chain of REST calls to GraphQL is usually the number of round trips per screen, because on a high-latency connection each extra request costs far more than the bytes it carries. The most common regret is discovering too late that the server now runs one database query per list item (the N+1 problem covered below).

Introduction: “Our REST API feels wasteful”

The motivation for GraphQL is a mismatch between how servers expose data (resources) and how clients consume it (screens). A profile screen needs a user, their five latest posts, and the comment count on each; a REST API designed around resources answers that with several requests or with a bespoke endpoint that the backend team must maintain for that one screen. GraphQL moves the shape of the response into the request, so the client describes the screen and the server fills it in.

Real-world pain points

Scenario 1: Over-fetching

You receive fields you never use. GraphQL lets the client request only what it needs.

Scenario 2: Under-fetching

You chain several REST calls to assemble a screen. GraphQL can fetch the graph in one request.

Scenario 3: Version sprawl

You maintain /v1, /v2, and so on. GraphQL typically evolves the schema without URL versions—clients opt into new fields.

That last point works because the server knows exactly which fields each client asks for. Adding a field never breaks anyone; removing one is done by marking it @deprecated(reason: "..."), watching field-usage metrics until traffic drops to zero, and only then deleting it. It is not magic, though: changing a field’s type or making a nullable argument required is still a breaking change, and without usage tracking you are guessing.


What is GraphQL?

Core traits

GraphQL is a query language for APIs.

Main benefits:

  • Precise data: ask for exactly what you need
  • Single endpoint: often one /graphql URL
  • Type system: strong contracts between client and server
  • Real time: subscriptions for push-style updates
  • Self-describing: the schema doubles as documentation

REST vs GraphQL:

  • REST: e.g. three calls (user, posts, comments)
  • GraphQL: one call for a composed query

The costs are just as real. HTTP caching mostly disappears, because every request is a POST to the same URL with a different body; CDNs and browser caches can’t help unless you adopt persisted queries sent as GET. Errors don’t map to status codes — a response is typically 200 OK with an errors array alongside partial data, so monitoring that only watches status codes will miss failures. And because clients can compose arbitrary queries, the server must defend itself against expensive ones. See the REST vs GraphQL vs gRPC comparison for when those trade-offs are worth it.


Apollo Server

Install

npm install @apollo/server graphql

Minimal server

// server.ts
import { ApolloServer } from '@apollo/server';
import { startStandaloneServer } from '@apollo/server/standalone';
const typeDefs = `#graphql
  type User {
    id: ID!
    name: String!
    email: String!
  }
  type Query {
    users: [User!]!
    user(id: ID!): User
  }
`;
const users = [
  { id: '1', name: 'John', email: '[email protected]' },
  { id: '2', name: 'Jane', email: '[email protected]' },
];
const resolvers = {
  Query: {
    users: () => users,
    user: (_: any, { id }: { id: string }) => 
      users.find(u => u.id === id),
  },
};
const server = new ApolloServer({
  typeDefs,
  resolvers,
});
const { url } = await startStandaloneServer(server, {
  listen: { port: 4000 },
});
console.log(`Server ready at ${url}`);

Two pieces make up every GraphQL server: typeDefs, the schema written in SDL (the #graphql comment only enables editor syntax highlighting), and resolvers, an object whose shape mirrors the schema. Apollo validates at startup that they fit together — a resolver for a field that does not exist in the schema fails with an error like Query.userz defined in resolvers, but not in schema. The top-level await requires running as an ES module ("type": "module" in package.json or a .mts file); under CommonJS the file fails to parse. Opening http://localhost:4000 in a browser shows Apollo Sandbox, an in-browser IDE that reads the schema through introspection. That same introspection is often disabled in production, where it gives attackers a map of your API; Apollo turns it off by default when NODE_ENV=production.


Defining the schema

Types

type User {
  id: ID!
  name: String!
  email: String!
  age: Int
  posts: [Post!]!
}
type Post {
  id: ID!
  title: String!
  content: String!
  author: User!
  comments: [Comment!]!
  createdAt: String!
}
type Comment {
  id: ID!
  text: String!
  author: User!
  post: Post!
}

The ! markers are the most consequential design decision in a schema. String! promises the field is never null; if a resolver returns null anyway, GraphQL does not just drop that field — the null propagates up to the nearest nullable parent, which can wipe out a whole list item or the entire data object, along with an error Cannot return null for non-nullable field Post.author. That is why many teams make fields that depend on other services or permissions nullable even when they are “usually” present: a nullable field degrades gracefully, a non-null one takes its siblings down with it. [Post!]! means “always a list, never containing nulls”, which is the right default for collections. Note also that createdAt: String! gives clients no guarantee about format; a custom DateTime scalar that serializes ISO 8601 is the usual improvement.

Query

type Query {
  users: [User!]!
  user(id: ID!): User
  posts(limit: Int, offset: Int): [Post!]!
  post(id: ID!): Post
}

Mutation

type Mutation {
  createUser(name: String!, email: String!): User!
  updateUser(id: ID!, name: String, email: String): User!
  deleteUser(id: ID!): Boolean!
  
  createPost(title: String!, content: String!, authorId: ID!): Post!
}

Offset pagination (limit, offset) is simple but shifts when rows are inserted, and it gets slower with large offsets because the database still walks past the skipped rows. Public GraphQL APIs usually adopt cursor-based “connections” (posts(first: 20, after: "cursor") returning edges and pageInfo) for that reason. For mutations, a common convention is a single input argument (createUser(input: CreateUserInput!)) and a payload type that can carry both the result and user-facing validation errors; it keeps signatures stable as fields are added. And authorId as a mutation argument is a security smell — in a real API the author should come from the authenticated context, not from something the client can set.

Subscription

type Subscription {
  postCreated: Post!
  commentAdded(postId: ID!): Comment!
}

Resolvers

Basic resolvers

const resolvers = {
  Query: {
    users: async () => {
      return await db.user.findMany();
    },
    
    user: async (_: any, { id }: { id: string }) => {
      return await db.user.findUnique({ where: { id: parseInt(id) } });
    },
  },
  Mutation: {
    createUser: async (_: any, { name, email }: { name: string; email: string }) => {
      return await db.user.create({
        data: { name, email },
      });
    },
  },
  User: {
    posts: async (parent: any) => {
      return await db.post.findMany({
        where: { authorId: parent.id },
      });
    },
  },
};

Every resolver receives four arguments: parent (the object returned by the resolver one level up), args (the field’s arguments), context (per-request state such as the authenticated user and database handles), and info (the parsed query). Fields without a resolver use a default that reads the property of the same name from parent, which is why User.name needs no code. Also note parseInt(id): the ID type is always serialized as a string, so databases with integer keys need a conversion, and invalid input like "abc" becomes NaN — validate before querying.

The User.posts resolver is where the N+1 problem comes from. For the query { users { posts { title } } }, the users resolver runs one query, then User.posts runs once per user — 1 + N queries. With nested lists it multiplies. The standard fix is DataLoader, which collects every key requested during one tick of the event loop and issues a single batched query:

import DataLoader from 'dataloader';

// Create per request (in the context function), never globally
const postsByAuthor = new DataLoader(async (authorIds: readonly number[]) => {
  const posts = await db.post.findMany({ where: { authorId: { in: [...authorIds] } } });
  // Results must be returned in the same order as the keys
  return authorIds.map(id => posts.filter(p => p.authorId === id));
});

// Resolver: User.posts: (parent, _args, ctx) => ctx.loaders.postsByAuthor.load(parent.id)

Two rules make DataLoader safe: create loaders per request (a global loader caches across users and leaks data between them), and return results in exactly the order of the input keys, with an entry — possibly empty or an Error — for every key. Getting the order wrong does not throw; it silently assigns posts to the wrong users.


Apollo Client (React)

Install

npm install @apollo/client graphql

Setup

// src/lib/apollo.ts
import { ApolloClient, InMemoryCache } from '@apollo/client';
export const client = new ApolloClient({
  uri: 'http://localhost:4000/graphql',
  cache: new InMemoryCache(),
});
// src/App.tsx
import { ApolloProvider } from '@apollo/client';
import { client } from './lib/apollo';
function App() {
  return (
    <ApolloProvider client={client}>
      <YourApp />
    </ApolloProvider>
  );
}

These snippets use the long-standing Apollo Client 3 API. Apollo Client 4 moved the React hooks and ApolloProvider to the @apollo/client/react entry point and expects an explicit link (link: new HttpLink({ uri })) instead of the uri shortcut, so check the version you install and its migration guide. The part that stays the same is InMemoryCache, and it is worth understanding before writing queries: Apollo normalizes every object in a response by __typename plus id (or _id), stores it once, and lets every query that references that object read the same record. That is why an updated User returned from a mutation automatically updates every list showing that user — if the mutation selects id. Types without an id field need keyFields in the cache’s typePolicies, or they are stored inside their parent and cannot be shared.


Using queries

useQuery

import { gql, useQuery } from '@apollo/client';
const GET_USERS = gql`
  query GetUsers {
    users {
      id
      name
      email
    }
  }
`;
function UsersList() {
  const { loading, error, data } = useQuery(GET_USERS);
  if (loading) return <p>Loading...</p>;
  if (error) return <p>Error: {error.message}</p>;
  return (
    <ul>
      {data.users.map((user) => (
        <li key={user.id}>
          {user.name} ({user.email})
        </li>
      ))}
    </ul>
  );
}

Variables

const GET_USER = gql`
  query GetUser($id: ID!) {
    user(id: $id) {
      id
      name
      email
      posts {
        id
        title
      }
    }
  }
`;
function UserProfile({ userId }: { userId: string }) {
  const { data } = useQuery(GET_USER, {
    variables: { id: userId },
  });
  return (
    <div>
      <h1>{data?.user.name}</h1>
      <p>{data?.user.email}</p>
      <h2>Posts</h2>
      <ul>
        {data?.user.posts.map((post) => (
          <li key={post.id}>{post.title}</li>
        ))}
      </ul>
    </div>
  );
}

data?.user.name hides a real edge case: user(id: ID!): User is nullable, so for a nonexistent id data.user is null and data?.user.name throws TypeError: Cannot read properties of null (reading 'name'). Render a “not found” state when data.user is null. Operation names like GetUser are not decoration either; they show up in server logs, tracing, and Apollo’s devtools, and they are how you find which screen issues an expensive query. By default useQuery uses the cache-first fetch policy — if all requested fields are cached, no request is made — so a component can look “stale” after data changed on the server; cache-and-network renders cached data and refreshes it in the background. For typed data instead of any, generate TypeScript types from the schema and your operations with GraphQL Code Generator.


Using mutations

useMutation

import { gql, useMutation } from '@apollo/client';
const CREATE_USER = gql`
  mutation CreateUser($name: String!, $email: String!) {
    createUser(name: $name, email: $email) {
      id
      name
      email
    }
  }
`;
function CreateUserForm() {
  const [createUser, { loading, error }] = useMutation(CREATE_USER, {
    refetchQueries: [{ query: GET_USERS }],
  });
  const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    const formData = new FormData(e.currentTarget);
    await createUser({
      variables: {
        name: formData.get('name'),
        email: formData.get('email'),
      },
    });
  };
  return (
    <form onSubmit={handleSubmit}>
      <input name="name" placeholder="Name" required />
      <input name="email" type="email" placeholder="Email" required />
      <button type="submit" disabled={loading}>
        {loading ? 'Creating...' : 'Create User'}
      </button>
      {error && <p>Error: {error.message}</p>}
    </form>
  );
}

refetchQueries is the simplest way to keep lists correct after a create: once the mutation finishes, Apollo re-runs GetUsers and the list includes the new user. It costs an extra round trip, and it only refetches queries you name. The alternative is updating the cache directly with the update option (cache.modify to append the new user reference to users), which is instant but has to be written per mutation. Updates to existing objects need neither, thanks to normalization. One more trap in this form: createUser rejects on GraphQL and network errors, so the await in handleSubmit throws an unhandled rejection unless you wrap it in try/catch — the error value from the hook is set either way, but the rejected promise still surfaces in the console.


Subscriptions

Server

import { WebSocketServer } from 'ws';
import { useServer } from 'graphql-ws/lib/use/ws';
import { makeExecutableSchema } from '@graphql-tools/schema';
const schema = makeExecutableSchema({ typeDefs, resolvers });
const wsServer = new WebSocketServer({
  server: httpServer,
  path: '/graphql',
});
useServer({ schema }, wsServer);

Subscriptions run over a separate WebSocket transport (the graphql-ws protocol), not through Apollo Server’s HTTP handler, which is why the schema is built with makeExecutableSchema and shared between both. The import path depends on the graphql-ws major version: graphql-ws/lib/use/ws is the v5 path, while v6 exposes it as graphql-ws/use/ws; the error Cannot find module 'graphql-ws/lib/use/ws' means the newer version is installed. Keep the return value of useServer and call its dispose() on shutdown so open subscriptions end cleanly.

The subscription resolvers themselves usually publish through a PubSub object: a mutation calls pubsub.publish('POST_CREATED', { postCreated: post }), and Subscription.postCreated.subscribe returns an async iterator for that topic. The in-memory PubSub from graphql-subscriptions only works within one process — with two server instances behind a load balancer, a post created on instance A never reaches subscribers connected to instance B. Production setups back it with Redis or another broker. Authentication also works differently here: there are no HTTP headers per operation, so clients send credentials in the connectionParams of the initial WebSocket message and the server validates them in onConnect or the context function.

Client

import { useSubscription, gql } from '@apollo/client';
const POST_CREATED = gql`
  subscription OnPostCreated {
    postCreated {
      id
      title
      author {
        name
      }
    }
  }
`;
function PostFeed() {
  const { data, loading } = useSubscription(POST_CREATED);
  if (loading) return <p>Waiting for posts...</p>;
  return (
    <div>
      <p>New post: {data?.postCreated.title}</p>
    </div>
  );
}

The client side needs a split link so that subscriptions go over WebSocket and everything else over HTTP: create a GraphQLWsLink with createClient({ url: 'ws://localhost:4000/graphql' }) and route with split() based on the operation type. useSubscription returns only the latest event, so this PostFeed shows one post at a time; to build a growing feed, use subscribeToMore on a useQuery for the initial list and merge each event into it.


Protecting the server from expensive queries

Because clients choose the query, a single request like { users { posts { comments { author { posts { comments { ... } } } } } } } can ask the server to do enormous work. This is the part of running GraphQL that tutorials skip and production traffic finds quickly. The usual layers are: a depth limit (reject queries nested beyond, say, 10 levels, e.g. with graphql-depth-limit), complexity or cost analysis that assigns weights to fields and multiplies list fields by their first/limit argument, a maximum page size enforced in resolvers, and persisted queries — only allowing operations whose hash was registered at build time — for first-party clients. Rate limiting by IP or user still applies, but it has to count cost, not requests, since one GraphQL request can be as expensive as a hundred REST calls.


Frequently asked questions (FAQ)

Q. GraphQL vs REST—which should I choose?

A. GraphQL fits complex, client-driven data needs. REST stays simpler and is often easier to cache with standard HTTP tooling. For mobile apps with varied UIs, GraphQL is a strong fit; for straightforward APIs, REST may be enough.

Q. How do I solve the N+1 problem?

A. Use DataLoader for per-request batching and caching of related loads (see the Resolvers section for the ordering and per-request rules).

Q. How should I approach caching?

A. Apollo Client caches query results on the client. For server-side caching, add Redis (or similar) where resolver work is expensive.

Q. Is GraphQL safe for production?

A. Yes. Many large companies run it in production—plan for performance, limits, and observability like any critical API layer.

Q. Can I add subscriptions to the startStandaloneServer setup from this article?

A. Not directly. startStandaloneServer creates and manages its own HTTP server, and Apollo Server 4 does not serve WebSocket subscriptions itself, so there is no httpServer to attach the graphql-ws WebSocketServer to. For subscriptions, create the server yourself with http.createServer, mount Apollo with expressMiddleware, and attach the WebSocketServer from the Subscriptions section to that same server on the /graphql path, using the same schema for both.