Skip to content
Learni
View all tutorials
Backend

How to Implement a GraphQL API with Apollo in 2026

Lire en français

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

terminal
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 src

These 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

src/schema.graphql
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

src/resolvers.ts
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

src/server.ts
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

src/resolvers.ts
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

src/server.ts
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)

src/context.ts
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.