Introduction
GraphQL, created by Facebook in 2012 and open-sourced in 2015, revolutionizes APIs by letting clients request exactly the data they need, avoiding the over-fetching and under-fetching of traditional REST APIs. In 2026, with Apollo Server v4 standalone, implementing a GraphQL API is simpler and more performant than ever, with native TypeScript support for strong typing.
This intermediate tutorial guides you step-by-step through building an API that manages books and authors: queries for reading, mutations for creating/updating, and resolvers with relationships. Imagine a digital library where a mobile client fetches a book and its author in a single request—that's GraphQL efficiency.
Why it matters: Modern APIs scale better, cut bandwidth by 30-50%, and accelerate frontend-backend development. At the end, you'll have a functional server, testable in the playground, ready for production with auth and caching. (142 words)
Prerequisites
- Node.js 20+ installed
- Basic knowledge of TypeScript and Node.js
- Editor like VS Code with GraphQL extension
- npm or yarn for dependencies
- 15 minutes to test locally
Initialize the project and install dependencies
mkdir graphql-api && cd graphql-api
npm init -y
npm install @apollo/server graphql typescript @types/node ts-node
npm install -D @types/graphql
mkdir srcThese commands create an empty Node.js project, install Apollo Server v4 (standalone), GraphQL, and TypeScript. ts-node lets you run TS directly. Stick to npm standards in 2026 if you're new to avoid yarn issues.
Define the base GraphQL schema
The schema is your API's contract: types, queries, and mutations. Like a blueprint, it describes the data without implementation details. We'll start with Book and Author types featuring a one-to-many relationship.
Create the schema.graphql file
type Book {
id: ID!
title: String!
authorId: ID!
published: Int!
}
type Author {
id: ID!
name: String!
books: [Book!]!
}
type Query {
books: [Book!]!
book(id: ID!): Book
authors: [Author!]!
author(id: ID!): Author
}
type Mutation {
createBook(title: String!, authorId: ID!, published: Int!): Book!
}This schema defines two types with required fields (!). Queries list or fetch items; the mutation creates a book. The books relation on Author will be resolved in resolvers. Pitfall: Forget the ! for non-nullables, and clients will crash.
Implement resolvers for queries and mock data
import { IResolvers } from '@graphql-tools/utils';
import { makeExecutableSchema } from '@graphql-tools/schema';
type Book = { id: string; title: string; authorId: string; published: number };
type Author = { id: string; name: string; books: Book[] };
const books: Book[] = [
{ id: '1', title: 'Le Petit Prince', authorId: '1', published: 1943 },
{ id: '2', title: '1984', authorId: '2', published: 1949 }
];
const authors: Author[] = [
{ id: '1', name: 'Saint-Exupéry', books: [] },
{ id: '2', name: 'George Orwell', books: [] }
];
authors[0].books = [books[0]];
authors[1].books = [books[1]];
const resolvers: IResolvers = {
Query: {
books: () => books,
book: (_parent, { id }) => books.find(b => b.id === id) || null,
authors: () => authors,
author: (_parent, { id }) => authors.find(a => a.id === id) || null,
},
Author: {
books: (parent: Author) => authors.find(a => a.id === parent.id)?.books || [],
}
};
export { resolvers, makeExecutableSchema };Resolvers map the schema to data (mock in-memory here). Query.books returns the list; Author.books resolves the relationship. Use @graphql-tools for an executable schema. Pitfall: Without resolvers for relationships, GraphQL returns null and breaks nested queries.
Start the Apollo Server
Apollo Server v4 is standalone—no Express needed. It exposes a playground at http://localhost:4000 for live query testing.
Create the main server
import { ApolloServer } from '@apollo/server/standalone';
import { readFileSync } from 'fs';
import { resolvers } from './resolvers.js';
import { makeExecutableSchema } from '@graphql-tools/schema.js';
const typeDefs = readFileSync('./src/schema.graphql', { encoding: 'utf-8' });
const schema = makeExecutableSchema({ typeDefs, resolvers });
const server = new ApolloServer({
schema,
});
(async () => {
const { url } = await server.listen({ port: 4000 });
console.log(`🚀 Server ready at ${url}`);
})();Reads the schema, links resolvers, and starts on port 4000. makeExecutableSchema combines everything. Run with npx ts-node src/server.ts. Pitfall: Forget .js on TS imports (ESM 2026 style) and the server won't start.
Add a working createBook mutation
import { IResolvers } from '@graphql-tools/utils';
import { makeExecutableSchema } from '@graphql-tools/schema';
type Book = { id: string; title: string; authorId: string; published: number };
type Author = { id: string; name: string; books: Book[] };
let books: Book[] = [
{ id: '1', title: 'Le Petit Prince', authorId: '1', published: 1943 },
{ id: '2', title: '1984', authorId: '2', published: 1949 }
];
let authors: Author[] = [
{ id: '1', name: 'Saint-Exupéry', books: [] },
{ id: '2', name: 'George Orwell', books: [] }
];
authors[0].books = [books[0]];
authors[1].books = [books[1]];
const nextId = () => (parseInt(books[books.length - 1]?.id || '0') + 1).toString();
const resolvers: IResolvers = {
Query: {
books: () => books,
book: (_parent, { id }) => books.find(b => b.id === id) || null,
authors: () => authors,
author: (_parent, { id }) => authors.find(a => a.id === id) || null,
},
Mutation: {
createBook: (_parent, { title, authorId, published }, _context) => {
const newBook: Book = { id: nextId(), title, authorId, published };
books.push(newBook);
const author = authors.find(a => a.id === authorId);
if (author) author.books.push(newBook);
return newBook;
},
},
Author: {
books: (parent: Author) => authors.find(a => a.id === parent.id)?.books || [],
}
};
export { resolvers, makeExecutableSchema };Adds Mutation.createBook: generates unique ID, pushes to arrays, updates relationship. Use let for mutable data. Test in playground: mutation { createBook(...) { id title } }. Pitfall: Without bidirectional updates (author.books), relationships break.
Handle context for authentication
At an intermediate level, add context to pass user/auth data to all resolvers, like a global per-request data tunnel.
Add context and auth middleware
import { ApolloServer } from '@apollo/server/standalone';
import { readFileSync } from 'fs';
import { resolvers } from './resolvers.js';
import { makeExecutableSchema } from '@graphql-tools/schema.js';
import type { ContextValue } from './context.js';
const typeDefs = readFileSync('./src/schema.graphql', { encoding: 'utf-8' });
const schema = makeExecutableSchema({ typeDefs, resolvers });
const server = new ApolloServer({
schema,
introspection: true,
});
(async () => {
const { url } = await server.listen({
port: 4000,
context: async ({ req }) => ({
user: req.headers.authorization === 'Bearer token' ? { id: 'user1' } : null,
} as ContextValue),
});
console.log(`🚀 Server ready at ${url}`);
})();Context extracts token from header, passes user to resolvers. Access via args.context in resolvers. Introspection enabled for playground. Pitfall: Context must be async, or headers won't work in production.
TypeScript types for context (context.ts file)
export interface ContextValue {
user: { id: string } | null;
}
export type ResolverContext = {
contextValue: ContextValue;
};Strict types for context avoid any. Import into resolvers for autocompletion. Essential in TS to scale without runtime bugs.
Best practices
- Validate inputs with class-validator in resolvers for security.
- Cache queries with Apollo's built-in caching or Redis.
- Paginate lists: Add first/after to queries to avoid N+1 issues.
- Use DataLoader to batch relationships and prevent N+1 queries.
- Generate TS types automatically with graphql-codegen for frontend.
Common errors to avoid
- N+1 problem: Resolve relationships without DataLoader, overload DB.
- Forget non-nullable: ! field without fallback = fatal GraphQL error.
- No context: Impossible stateless auth, security compromised.
- Static schema: Without codegen, types drift between BE/FE.
Next steps
Master subscriptions with WebSockets via Apollo. Integrate Prisma for a real DB. Check our Learni trainings on GraphQL and Apollo for pro-level: federation, stitching, and perf tuning. Official docs: Apollo GraphQL.