Overview

GraphQL lets clients request exactly the data they need in a single round trip. Unlike REST, where endpoints are fixed, a GraphQL API exposes a typed schema, and clients describe the shape of the response. This tutorial builds a working GraphQL API using Apollo Server and Node.js.

GraphQL vs REST

AspectRESTGraphQL
EndpointsOne per resourceSingle /graphql endpoint
Over-fetchingCommonClient controls the response shape
Under-fetchingRequires multiple requestsSingle query for nested data
Type systemInformalStrongly typed schema
CachingHTTP caching works naturallyRequires client-side normalization
VersioningURL versions or headersSchema evolution via deprecation

Project Setup

mkdir graphql-demo
cd graphql-demo
npm init -y
npm install @apollo/server graphql
npm pkg set type=module

Step 1: Define the Schema

GraphQL schemas use the Schema Definition Language (SDL). Create schema.js:

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

  type Post {
    id: ID!
    title: String!
    body: String!
    published: Boolean!
    author: Author!
  }

  type Query {
    posts(published: Boolean): [Post!]!
    post(id: ID!): Post
    authors: [Author!]!
    author(id: ID!): Author
  }

  type Mutation {
    createPost(input: CreatePostInput!): Post!
    publishPost(id: ID!): Post
    deletePost(id: ID!): Boolean!
  }

  input CreatePostInput {
    title: String!
    body: String!
    authorId: ID!
  }
`;
ElementMeaning
typeObject type with fields
QueryRead operations
MutationWrite operations
inputStructured argument type
!Non-nullable field
[Post!]!Non-null array of non-null posts

Step 2: Write the Resolvers

Resolvers are functions that return the data for each field. Create resolvers.js:

const authors = [
  { id: '1', name: 'Alice' },
  { id: '2', name: 'Bob' },
];

const posts = [
  { id: '1', title: 'Getting Started with GraphQL', body: '...', published: true, authorId: '1' },
  { id: '2', title: 'Advanced Resolvers', body: '...', published: false, authorId: '1' },
  { id: '3', title: 'Schema Design Tips', body: '...', published: true, authorId: '2' },
];

let nextPostId = 4;

export const resolvers = {
  Query: {
    posts: (_, { published }) =>
      published === undefined
        ? posts
        : posts.filter(p => p.published === published),
    post: (_, { id }) => posts.find(p => p.id === id),
    authors: () => authors,
    author: (_, { id }) => authors.find(a => a.id === id),
  },

  Mutation: {
    createPost: (_, { input }) => {
      const post = {
        id: String(nextPostId++),
        title: input.title,
        body: input.body,
        published: false,
        authorId: input.authorId,
      };
      posts.push(post);
      return post;
    },

    publishPost: (_, { id }) => {
      const post = posts.find(p => p.id === id);
      if (!post) return null;
      post.published = true;
      return post;
    },

    deletePost: (_, { id }) => {
      const index = posts.findIndex(p => p.id === id);
      if (index === -1) return false;
      posts.splice(index, 1);
      return true;
    },
  },

  // Field-level resolvers
  Post: {
    author: (post) => authors.find(a => a.id === post.authorId),
  },

  Author: {
    posts: (author) => posts.filter(p => p.authorId === author.id),
  },
};

The Post.author and Author.posts resolvers turn the relationship into a graph: a client can query a post, then its author, then all of that author's posts, all in one request.

Step 3: Start Apollo Server

Create server.js:

import { ApolloServer } from '@apollo/server';
import { startStandaloneServer } from '@apollo/server/standalone';
import { typeDefs } from './schema.js';
import { resolvers } from './resolvers.js';

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

const { url } = await startStandaloneServer(server, {
  listen: { port: 4000 },
});

console.log(`GraphQL server ready at ${url}`);
node server.js

Open http://localhost:4000 to use Apollo Sandbox, an interactive query explorer.

Step 4: Run Queries

query GetPublishedPosts {
  posts(published: true) {
    id
    title
    author {
      name
      posts {
        title
      }
    }
  }
}

Note that the response contains exactly the fields requested — no more, no less.

Step 5: Run Mutations

mutation CreateNewPost($input: CreatePostInput!) {
  createPost(input: $input) {
    id
    title
    published
    author {
      name
    }
  }
}

Variables:

{
  "input": {
    "title": "My First Post",
    "body": "Hello GraphQL",
    "authorId": "1"
  }
}

Solving the N+1 Problem with DataLoader

If Post.author performs a database query per post, fetching 100 posts triggers 100 additional queries. DataLoader batches these into one.

npm install dataloader
import DataLoader from 'dataloader';

const authorLoader = new DataLoader(async (ids) => {
  const rows = await db.authors.findMany({ where: { id: { in: ids } } });
  const map = new Map(rows.map(r => [r.id, r]));
  return ids.map(id => map.get(id));
});
Post: {
  author: (post, _, { loaders }) => loaders.author.load(post.authorId),
}

Create a new DataLoader per request to avoid caching across users.

Authentication and Context

const { url } = await startStandaloneServer(server, {
  listen: { port: 4000 },
  context: async ({ req }) => {
    const token = req.headers.authorization?.replace('Bearer ', '');
    const user = token ? verifyToken(token) : null;
    return { user, loaders: { author: authorLoader } };
  },
});

Then enforce in resolvers:

createPost: (_, { input }, { user }) => {
  if (!user) throw new GraphQLError('Unauthorized', {
    extensions: { code: 'UNAUTHENTICATED' },
  });
  // ...
}

Schema Design Guidelines

  • Model the graph, not the database tables. Types should reflect what clients need.
  • Use input types for mutations, never reuse output types as input.
  • Return a payload type from mutations instead of a scalar when you may need to add fields later.
  • Deprecate fields with @deprecated(reason: "...") rather than removing them.
  • Paginate lists with cursor-based connections (Relay style) for stability.

Common Pitfalls

PitfallProblemFix
N+1 queriesDatabase overloadUse DataLoader
Unbounded list fieldsHuge responses, memory spikesAdd pagination arguments
No query depth limitMalicious deeply nested queriesUse graphql-depth-limit
Leaking internal errorsStack traces exposed to clientsFormat errors before returning
Exposing the schema in production without limitsAbuse and cost overrunsAdd rate limiting and cost analysis