A GraphQL API can feel like magic, but it’s really just a smart proxy that knows how to translate incoming queries into requests your existing REST APIs understand.

Let’s see it in action. Imagine we have a simple REST API for users:

GET /users -> [{"id": 1, "name": "Alice", "email": "alice@example.com"}, ...] GET /users/1 -> {"id": 1, "name": "Alice", "email": "alice@example.com"}

And another for posts:

GET /posts -> [{"id": 101, "title": "GraphQL Intro", "userId": 1}, ...] GET /posts/101 -> {"id": 101, "title": "GraphQL Intro", "userId": 1}

We want to expose these via GraphQL, allowing clients to ask for a user and their posts in a single query:

query GetUserAndPosts($userId: ID!) {
  user(id: $userId) {
    id
    name
    posts {
      id
      title
    }
  }
}

To achieve this, we’ll use a GraphQL server library, like apollo-server in Node.js. The core idea is to define a GraphQL schema that describes the data available, and then write resolvers that tell the GraphQL server how to fetch that data from our REST APIs.

Here’s a simplified schema definition:

type User {
  id: ID!
  name: String!
  posts: [Post!]!
}

type Post {
  id: ID!
  title: String!
  user: User! # We might not need this if we don't query posts and then their user
}

type Query {
  user(id: ID!): User
  users: [User!]!
}

This schema defines User and Post types, mirroring our REST resources but with an important difference: the posts field on User and the user field on Post are defined as relationships.

Now, the resolvers. These are functions that the GraphQL server calls when a query comes in.

const resolvers = {
  Query: {
    user: async (_, { id }) => {
      // Fetch user from REST API
      const response = await fetch(`http://localhost:3000/users/${id}`);
      return response.json();
    },
    users: async () => {
      // Fetch all users from REST API
      const response = await fetch(`http://localhost:3000/users`);
      return response.json();
    },
  },
  User: {
    posts: async (user) => {
      // Fetch posts for this specific user from REST API
      const response = await fetch(`http://localhost:3000/posts?userId=${user.id}`);
      return response.json();
    },
  },
  Post: {
    user: async (post) => {
      // Fetch the user for this post from REST API
      const response = await fetch(`http://localhost:3000/users/${post.userId}`);
      return response.json();
    },
  },
};

When a client sends the GetUserAndPosts query, the GraphQL server executes it like this:

  1. It calls the Query.user resolver with id: "1". This fetches user data from GET /users/1.
  2. The User type has a posts field. The GraphQL server sees that the query asks for posts, so it calls the User.posts resolver.
  3. The User.posts resolver receives the user object fetched in step 1 (specifically user.id), and makes a request to GET /posts?userId=1.
  4. The results from both requests are assembled and returned to the client.

This pattern, where a GraphQL field resolver makes a REST call to fetch related data, is often called "N+1 problem" in reverse. Instead of fetching N items and then executing N additional queries, we’re executing one query for the primary resource and then one query per requested nested relationship. The GraphQL server is smart enough to batch these requests if it can, or at least only fetch what’s explicitly asked for.

The primary benefit here is client-driven data fetching. Clients can request exactly the data they need, avoiding over-fetching common with REST. If a mobile client only needs the user’s name, it can ask for user(id: $userId) { name } and only receive the name, not the full user object or their posts.

The real magic happens when you realize that the User.posts resolver can be written to fetch all posts (GET /posts) and then filter them in memory, or it can directly query your backend for posts belonging to that user (GET /posts?userId=${user.id}). The latter is usually more efficient. The GraphQL server orchestrates these calls.

Most people don’t realize that the posts field on the User type in the schema doesn’t have to make a separate REST call for each user if you’re fetching multiple users. If your Query.users resolver fetches all users from GET /users, and then a client asks for users { id name posts { id } }, the GraphQL server will call Query.users once. Then, for each user returned, it will call the User.posts resolver. You can optimize this by having the User.posts resolver, when called in a batch context (e.g., multiple users requested), fetch all necessary posts in a single REST call (e.g., GET /posts) and then efficiently map them to their respective users. Libraries like dataloader are commonly used to batch and cache these underlying data fetches.

The next step is to explore how to handle mutations, which are GraphQL’s way of modifying data on the server.

Want structured learning?

Take the full Graphql-tools course →