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
| Aspect | REST | GraphQL |
|---|---|---|
| Endpoints | One per resource | Single /graphql endpoint |
| Over-fetching | Common | Client controls the response shape |
| Under-fetching | Requires multiple requests | Single query for nested data |
| Type system | Informal | Strongly typed schema |
| Caching | HTTP caching works naturally | Requires client-side normalization |
| Versioning | URL versions or headers | Schema 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!
}
`;
| Element | Meaning |
|---|---|
type | Object type with fields |
Query | Read operations |
Mutation | Write operations |
input | Structured 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
inputtypes 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
| Pitfall | Problem | Fix |
|---|---|---|
| N+1 queries | Database overload | Use DataLoader |
| Unbounded list fields | Huge responses, memory spikes | Add pagination arguments |
| No query depth limit | Malicious deeply nested queries | Use graphql-depth-limit |
| Leaking internal errors | Stack traces exposed to clients | Format errors before returning |
| Exposing the schema in production without limits | Abuse and cost overruns | Add rate limiting and cost analysis |
