Design a GraphQL API with Apollo Server

Build a typed Bookstore API with GraphQL schemas, resolvers, and mutations.

Introduction

30 Second Summary

Every time you use an app that loads your profile, your posts, and your friends list, there's an API behind the scenes fetching that data. Traditional APIs often send back more information than you actually need, or force you to make multiple separate requests to piece together related data.

In this project, you will build a GraphQL API from scratch that lets clients request exactly the data they need in a single query. You'll design a schema for a bookstore, implement resolver functions, and test everything using a built-in playground.

What You'll Build

A Bookstore GraphQL API built with Apollo Server and TypeScript that serves books and authors with relationships between them.

By the end of this project, you'll have:

  • A running GraphQL API server with a well-designed schema serving book and author data.
  • Implemented queries with arguments and mutations with input types following GraphQL best practices.
  • Tested your API using the Apollo Explorer built-in playground to run queries and mutations interactively.
  • Secret Mission: Add filtering and pagination to your books query to make your API production-ready.

Are there any prerequisites?

Basic familiarity with JavaScript is helpful. No prior GraphQL or TypeScript experience is required.

Set Up Your Project

Before you can design your GraphQL schema or write a single resolver, you need a working TypeScript project with the right tools in place.

In this step, you will install Node.js, create your project folder, and pull in Apollo Server as your GraphQL server framework.

In this step, get ready to:
  • Verify that Node.js v20+ and npm are installed.
  • Initialize a TypeScript project with Apollo Server dependencies.
  • Create the file structure for your API.
Verify Node.js and npm

Apollo Server requires Node.js version 20 or higher to run.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal (macOS) or PowerShell (Windows) and press Enter to open it.
  • Check your Node.js version by running this command:
node --version

✔️ I see version 20 or higher

Your Node.js version is ready to go. Confirm npm is available too by running:

npm --version

You should see a version number printed. npm is bundled with Node.js, so if Node works, npm works too.

ⓧ I see an older version

You have Node.js installed, but it is too old for Apollo Server. You need version 20 or higher.

  • Go to https://nodejs.org and download the LTS version.
  • Run the installer. It will replace your older version.
  • Close and reopen your terminal, then run node --version again to confirm.

Why a new terminal window?

Your terminal caches the path to executables when it opens. A fresh window picks up the newly installed version.

ⓧ Command not found

Node.js is not installed on your machine yet.

  • Go to https://nodejs.org and download the LTS version (currently v24).
  • Run the installer and follow the prompts. Accept all defaults.
  • Close and reopen your terminal after installation completes.
  • Run node --version again. You should see a version number starting with v20 or higher.
Initialize the project and install dependencies

Now you will create a dedicated folder for your project and install the packages Apollo Server needs.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir graphql-bookstore
cd graphql-bookstore
  • Initialize a new npm project by running:
npm init -y
  • Install Apollo Server and the GraphQL library by running:
npm install @apollo/server graphql
  • Install the TypeScript compiler and Node.js type definitions as dev dependencies by running:
npm install --save-dev typescript @types/node
  • Confirm the packages installed by running:
ls node_modules

You should see a list of folders including @apollo and graphql.

Seeing permission errors or install failures?

Make sure you are inside the graphql-bookstore folder before running the install commands. Run pwd to check your current directory.

If npm throws a network error, check your internet connection and try again.

Why can't I install my npm packages?

Next, you need two configuration files: one for TypeScript and one to tell Node.js this project uses ES modules.

  • Create a new file called tsconfig.json in the root of your graphql-bookstore folder and paste this configuration:
{
  "compilerOptions": {
    "rootDirs": ["src"],
    "outDir": "dist",
    "lib": ["es2023"],
    "target": "es2023",
    "module": "esnext",
    "moduleResolution": "node",
    "esModuleInterop": true,
    "types": ["node"]
  }
}

What does this configuration do?

  • rootDirs tells TypeScript your source code lives in the src/ folder.
  • outDir places compiled JavaScript into a dist/ folder.
  • module and target set to es2023 ensures modern JavaScript features like top-level await work out of the box.
  • Save tsconfig.json.

Now update your package.json so Node.js knows this project uses ES modules and has scripts to compile and run the server.

  • Open package.json in your editor.
  • Add the following line after the "main" field:
"type": "module",
  • Find the "scripts" section and replace it with:
"scripts": {
  "compile": "tsc",
  "start": "npm run compile && node ./dist/index.js"
},

What do these scripts do?

  • compile runs the TypeScript compiler to convert your .ts files into JavaScript.
  • start compiles first, then runs the output with Node.js. One command to build and launch your server.
  • Save package.json.

Not sure where to add the new fields?

The "type": "module" line can go anywhere at the top level of the JSON object. Placing it right after "main" keeps it tidy.

How do I edit package.json correctly for my Apollo Server TypeScript project?

Create the project structure

Your project needs a src/ folder with four TypeScript files. Each file will handle a different responsibility.

  • Create the src directory and the four files by running these commands:

macOS

mkdir src
touch src/index.ts src/schema.ts src/resolvers.ts src/data.ts

Windows

mkdir src
New-Item src/index.ts, src/schema.ts, src/resolvers.ts, src/data.ts

What is each file for?

  • index.ts is the entry point that starts your server.
  • schema.ts defines your GraphQL types, queries, and mutations.
  • resolvers.ts contains the functions that fetch data for each field.
  • data.ts holds your in-memory book and author data.
  • Confirm the files exist by running:
ls src

You should see data.ts, index.ts, resolvers.ts, and schema.ts in the output.

Finally, open your project in your code editor so you are ready to write code in the next step.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Cursor (or VS Code) and press Enter to open it.
  • Click File in the top menu bar.
  • Click Open Folder.
  • Navigate to your Desktop and select the graphql-bookstore folder, then click Open.

You should see the src/ folder, tsconfig.json, and package.json listed in the Explorer sidebar on the left.

Don't see any files in the sidebar?

Make sure you opened the graphql-bookstore folder itself, not the Desktop or a parent folder. The folder name should appear at the top of the Explorer panel.

Why can't I see my project files in my editor?

Your project skeleton is ready. Next up, you will design your GraphQL schema and model books and authors as a connected graph.

Design Your GraphQL Schema

Your project scaffolding is ready. Now it's time for the most important part of any GraphQL API: designing the schema.

A GraphQL schema defines every piece of data your API can serve and every operation clients can perform. You'll write this schema using Schema Definition Language (SDL), a human-readable syntax that acts as a contract between your server and any client that connects to it.

In this step, get ready to:
  • Define Book and Author types with a relationship between them.
  • Design queries and mutations with input types.
  • Export your schema as a tagged template literal.
Define the data types

Every GraphQL schema starts with object types that model your domain. For a bookstore, the core entities are books and authors.

The relationship between them is what makes GraphQL powerful. A Book has one Author, and an Author can have many Books. Clients can traverse this relationship in a single query without making extra requests.

  • Open src/schema.ts in your editor and add the following type definitions:
export const typeDefs = `#graphql
  type Book {
    id: ID!
    title: String!
    publishedYear: Int
    genre: String
    author: Author!
  }

  type Author {
    id: ID!
    name: String!
    bio: String
    books: [Book!]!
  }

What does this code do?

You're defining two object types in SDL. The exclamation mark (!) means a field is non-null, so the server guarantees it will always return a value.

  • ID! is a unique identifier that will never be null.
  • String! means a required string field (like title or name).
  • Int without the ! means the field is optional (it can return null).
  • author: Author! creates the relationship. Every Book always has an Author.
  • books: [Book!]! means the Author always returns a list, and every item in that list is a non-null Book.

You won't see a change yet. The next chunk completes the schema so you can compile.

Define queries and mutations

Object types describe what data looks like. Queries and mutations describe what clients can do with that data.

GraphQL best practice is to use dedicated input types for mutations instead of passing individual arguments. This keeps your schema organized and makes it easier to add fields later without breaking existing clients.

  • In src/schema.ts, add the following below the Author type to complete the schema:

  input AddBookInput {
    title: String!
    publishedYear: Int
    genre: String
    authorId: ID!
  }

  input AddAuthorInput {
    name: String!
    bio: String
  }

  type Query {
    books: [Book!]!
    book(id: ID!): Book
    authors: [Author!]!
    author(id: ID!): Author
  }

  type Mutation {
    addBook(input: AddBookInput!): Book!
    addAuthor(input: AddAuthorInput!): Author!
  }
`;

What does this code do?

This adds the input types, queries, and mutations that define what clients can do with your API.

  • input AddBookInput and input AddAuthorInput are purpose-built types for creating new records. They only include the fields a client needs to supply (no id since the server generates that).
  • book(id: ID!): Book accepts an argument. The client passes an ID and gets back a single Book (or null if not found).
  • addBook(input: AddBookInput!): Book! takes the input type and returns the newly created Book.
  • The closing `; ends the template literal string that holds the entire schema.
  • Save src/schema.ts.
  • Confirm the file compiles by running:
npm run compile
  • The command should complete with no errors. You'll also see a new dist/ folder appear in your project.

Seeing a compilation error?

  • Check that the template literal opens with a backtick after = `#graphql and closes with `; on the last line. A missing backtick is the most common cause.
  • Make sure there are no extra characters after the closing `; on the last line.
  • Verify the file starts with export const typeDefs. A missing export keyword won't cause a compile error, but will cause issues later.

Why won't my schema file compile?

Export the schema

Your schema is written as a tagged template literal string. The #graphql tag at the start of the string doesn't affect runtime behavior. It signals to your editor that the string contains GraphQL SDL, which enables syntax highlighting and autocomplete inside the template literal.

Why use a tagged template literal for the schema?

Apollo Server accepts the schema as a plain string. Wrapping it in a template literal tagged with #graphql gives you two benefits: multi-line support without concatenation, and editor tooling that understands the string is GraphQL (not arbitrary text).

The export keyword makes typeDefs available to other files. You'll import it into your server entry point in a later step.

Your schema now defines 2 object types (Book and Author), 2 input types, 4 queries, and 2 mutations. That's a complete API contract.

✔️ Awesome, I've got everything!

Great. Double check you've saved src/schema.ts and that npm run compile runs without errors.

ⓧ I'd like to double check the full code

Here's the complete src/schema.ts file. Make sure yours matches exactly:

export const typeDefs = `#graphql
  type Book {
    id: ID!
    title: String!
    publishedYear: Int
    genre: String
    author: Author!
  }

  type Author {
    id: ID!
    name: String!
    bio: String
    books: [Book!]!
  }

  input AddBookInput {
    title: String!
    publishedYear: Int
    genre: String
    authorId: ID!
  }

  input AddAuthorInput {
    name: String!
    bio: String
  }

  type Query {
    books: [Book!]!
    book(id: ID!): Book
    authors: [Author!]!
    author(id: ID!): Author
  }

  type Mutation {
    addBook(input: AddBookInput!): Book!
    addAuthor(input: AddAuthorInput!): Author!
  }
`;

Your schema is the blueprint. Next up, you'll bring it to life by writing resolver functions that connect each field to actual data.

Implement Resolvers and Data

Your schema defines the contract. It tells clients what data exists and how to ask for it. Now you need the other half: the actual data and the functions that fetch it.

In GraphQL, resolver functions are the bridge between the schema and your data source. Each field in your schema can have a resolver that tells the server how to get the value for that field. In this step, you will create sample data and write resolvers that fulfill the contract your schema defined.

In this step, get ready to:
  • Create in-memory book and author data with TypeScript interfaces.
  • Write query resolvers that return data for each Query field.
  • Write mutation resolvers and nested resolvers for relationships.
Create in-memory data

Before resolvers can return anything, they need a data source. You will define TypeScript interfaces that mirror your schema types, then create arrays of sample data that resolvers can query.

  • Open src/data.ts and add the following interfaces that define the shape of your data:
export interface BookData {
  id: string;
  title: string;
  publishedYear: number | null;
  genre: string | null;
  authorId: string;
}

export interface AuthorData {
  id: string;
  name: string;
  bio: string | null;
}

What does this code do?

These interfaces define the shape of data stored in memory. They mirror your GraphQL types but include an authorId field on BookData that links a book to its author.

Nullable fields like publishedYear use number | null in TypeScript, matching the nullable Int in your schema.

  • Below the interfaces in src/data.ts, add the sample data arrays:
export const books: BookData[] = [
  { id: "1", title: "The Great Gatsby", publishedYear: 1925, genre: "Fiction", authorId: "1" },
  { id: "2", title: "Tender Is the Night", publishedYear: 1934, genre: "Fiction", authorId: "1" },
  { id: "3", title: "To Kill a Mockingbird", publishedYear: 1960, genre: "Fiction", authorId: "2" },
  { id: "4", title: "Go Set a Watchman", publishedYear: 2015, genre: "Fiction", authorId: "2" },
  { id: "5", title: "1984", publishedYear: 1949, genre: "Dystopian", authorId: "3" },
  { id: "6", title: "Animal Farm", publishedYear: 1945, genre: "Satire", authorId: "3" },
];

export const authors: AuthorData[] = [
  { id: "1", name: "F. Scott Fitzgerald", bio: "American novelist of the Jazz Age" },
  { id: "2", name: "Harper Lee", bio: "American novelist known for her Pulitzer Prize-winning work" },
  { id: "3", name: "George Orwell", bio: "English novelist and essayist" },
];

What does this code do?

These arrays act as your in-memory database. Each book has an authorId that references an author's id. This is the relationship your nested resolvers will use to connect books to authors.

You have 3 authors with 2 books each, giving you enough data to see relationships working in queries.

  • Save src/data.ts.
  • Confirm the file compiles by running:
npm run compile

A successful compile produces no output and returns you to the command prompt.

Seeing a TypeScript error?

  • Check that each array entry uses the exact field names from the interface (id, title, publishedYear, genre, authorId).
  • Make sure the interfaces are defined above the arrays in the same file.
  • Confirm you exported both the interfaces and the arrays with the export keyword.

Help me debug my TypeScript compilation error in data.ts.

✔️ Awesome, I've got everything!

Great. Double check you have saved src/data.ts before moving on.

ⓧ I'd like to double check the full code

export interface BookData {
  id: string;
  title: string;
  publishedYear: number | null;
  genre: string | null;
  authorId: string;
}

export interface AuthorData {
  id: string;
  name: string;
  bio: string | null;
}

export const books: BookData[] = [
  { id: "1", title: "The Great Gatsby", publishedYear: 1925, genre: "Fiction", authorId: "1" },
  { id: "2", title: "Tender Is the Night", publishedYear: 1934, genre: "Fiction", authorId: "1" },
  { id: "3", title: "To Kill a Mockingbird", publishedYear: 1960, genre: "Fiction", authorId: "2" },
  { id: "4", title: "Go Set a Watchman", publishedYear: 2015, genre: "Fiction", authorId: "2" },
  { id: "5", title: "1984", publishedYear: 1949, genre: "Dystopian", authorId: "3" },
  { id: "6", title: "Animal Farm", publishedYear: 1945, genre: "Satire", authorId: "3" },
];

export const authors: AuthorData[] = [
  { id: "1", name: "F. Scott Fitzgerald", bio: "American novelist of the Jazz Age" },
  { id: "2", name: "Harper Lee", bio: "American novelist known for her Pulitzer Prize-winning work" },
  { id: "3", name: "George Orwell", bio: "English novelist and essayist" },
];
Write query resolvers

Resolvers are functions that the server calls to get the value for each field in your schema. Every field in your Query type needs a corresponding resolver that knows how to fetch or compute that data.

  • Open src/resolvers.ts and add the import and Query resolvers:
import { books, authors, BookData, AuthorData } from "./data.js";

export const resolvers = {
  Query: {
    // Return all books
    books: (): BookData[] => books,
    // Find a single book by its ID
    book: (_: unknown, args: { id: string }): BookData | undefined =>
      books.find((book) => book.id === args.id),
    // Return all authors
    authors: (): AuthorData[] => authors,
    // Find a single author by their ID
    author: (_: unknown, args: { id: string }): AuthorData | undefined =>
      authors.find((author) => author.id === args.id),
  },
};

What does this code do?

Each resolver function receives two main arguments: the parent object (unused here, so named _) and an args object containing any arguments passed in the query.

The books and authors resolvers simply return the full arrays. The book and author resolvers use args.id to find a specific record.

Why does the import use .js instead of .ts?

TypeScript compiles .ts files into .js files in the dist/ folder. At runtime, Node.js loads the compiled .js output. The import path must match what exists at runtime, so you write ./data.js even though the source file is data.ts.

  • Save src/resolvers.ts.

You won't see a visible change yet. The next chunk completes the resolvers with mutations and relationship logic.

Write nested resolvers and mutations

Nested resolvers handle the relationship fields in your schema. When a client queries a book's author, GraphQL calls the Book.author resolver with the book as the parent. Mutations let clients create new data.

  • In src/resolvers.ts, find the closing }; of the resolvers object. Replace everything from the Query closing brace onward so that your resolvers object includes the Mutation, Book, and Author keys:
import { books, authors, BookData, AuthorData } from "./data.js";

export const resolvers = {
  Query: {
    // Return all books
    books: (): BookData[] => books,
    // Find a single book by its ID
    book: (_: unknown, args: { id: string }): BookData | undefined =>
      books.find((book) => book.id === args.id),
    // Return all authors
    authors: (): AuthorData[] => authors,
    // Find a single author by their ID
    author: (_: unknown, args: { id: string }): AuthorData | undefined =>
      authors.find((author) => author.id === args.id),
  },

  Mutation: {
    // Create a new book and add it to the array
    addBook: (_: unknown, args: { input: { title: string; publishedYear?: number; genre?: string; authorId: string } }): BookData => {
      const newBook: BookData = {
        id: String(books.length + 1),
        title: args.input.title,
        publishedYear: args.input.publishedYear ?? null,
        genre: args.input.genre ?? null,
        authorId: args.input.authorId,
      };
      books.push(newBook);
      return newBook;
    },
    // Create a new author and add it to the array
    addAuthor: (_: unknown, args: { input: { name: string; bio?: string } }): AuthorData => {
      const newAuthor: AuthorData = {
        id: String(authors.length + 1),
        title: args.input.name,
        bio: args.input.bio ?? null,
      };
      authors.push(newAuthor);
      return newAuthor;
    },
  },

  Book: {
    // Resolve the author field by finding the author matching authorId
    author: (parent: BookData): AuthorData | undefined =>
      authors.find((author) => author.id === parent.authorId),
  },

  Author: {
    // Resolve the books field by filtering books that belong to this author
    books: (parent: AuthorData): BookData[] =>
      books.filter((book) => book.authorId === parent.id),
  },
};

What does this code do?

The Mutation resolvers create new records. They generate an ID, build an object matching the BookData or AuthorData interface, push it into the array, and return the new record.

The Book.author resolver receives the current book as parent and finds the matching author by ID. The Author.books resolver receives the current author as parent and filters for all books with that author's ID.

This is the power of nested resolvers. GraphQL calls them automatically when a client requests a relationship field, so one query can traverse the entire graph.

  • Save src/resolvers.ts.
  • Confirm everything compiles by running:
npm run compile

A successful compile produces no output and returns you to the command prompt. This confirms your resolvers match the types defined in data.ts.

Seeing a compile error?

  • Check that the import path uses ./data.js (not ./data.ts). TypeScript requires the runtime extension in ESM projects.
  • Make sure the resolvers object has proper comma separation between each key (Query, Mutation, Book, Author).
  • Verify all arrow functions use the correct types from your interfaces in data.ts.

Help me debug my TypeScript compile error in resolvers.ts.

✔️ Awesome, I've got everything!

Great. Double check you have saved src/resolvers.ts before moving on.

ⓧ I'd like to double check the full code

import { books, authors, BookData, AuthorData } from "./data.js";

export const resolvers = {
  Query: {
    books: (): BookData[] => books,
    book: (_: unknown, args: { id: string }): BookData | undefined =>
      books.find((book) => book.id === args.id),
    authors: (): AuthorData[] => authors,
    author: (_: unknown, args: { id: string }): AuthorData | undefined =>
      authors.find((author) => author.id === args.id),
  },

  Mutation: {
    addBook: (_: unknown, args: { input: { title: string; publishedYear?: number; genre?: string; authorId: string } }): BookData => {
      const newBook: BookData = {
        id: String(books.length + 1),
        title: args.input.title,
        publishedYear: args.input.publishedYear ?? null,
        genre: args.input.genre ?? null,
        authorId: args.input.authorId,
      };
      books.push(newBook);
      return newBook;
    },
    addAuthor: (_: unknown, args: { input: { name: string; bio?: string } }): AuthorData => {
      const newAuthor: AuthorData = {
        id: String(authors.length + 1),
        title: args.input.name,
        bio: args.input.bio ?? null,
      };
      authors.push(newAuthor);
      return newAuthor;
    },
  },

  Book: {
    author: (parent: BookData): AuthorData | undefined =>
      authors.find((author) => author.id === parent.authorId),
  },

  Author: {
    books: (parent: AuthorData): BookData[] =>
      books.filter((book) => book.authorId === parent.id),
  },
};

Your data layer and resolvers are complete. Next up, you will wire everything together into a running server and test your API with real queries.

Start the Server and Test Your API

Your schema, data, and resolvers are all in place. The final piece is wiring them together into a running server so you can actually send queries to your API.

In this step, you will create the entry point that launches Apollo Server and then use the built-in Apollo Explorer to test your queries and mutations live.

In this step, get ready to:
  • Write the server entry point that starts Apollo Server on port 4000.
  • Test queries for books and authors with nested relationships.
  • Test mutations that create new books and authors.
Set up the Apollo Server entry point

The entry point file creates the server instance with your schema and resolvers, then starts listening for incoming requests.

  • Open src/index.ts and add the following code:
import { ApolloServer } from "@apollo/server";
import { startStandaloneServer } from "@apollo/server/standalone";
import { typeDefs } from "./schema.js";
import { resolvers } from "./resolvers.js";

// Create the Apollo Server instance with your schema and resolvers
const server = new ApolloServer({
  typeDefs,
  resolvers,
});

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

console.log(`Server ready at: ${url}`);

What does this code do?

The imports pull in ApolloServer (the server class) and startStandaloneServer (a helper that runs the server on Node's built-in HTTP server without needing Express).

You pass your typeDefs and resolvers into the ApolloServer constructor. This tells Apollo how your schema is shaped and how to resolve each field.

The startStandaloneServer function returns a url string that you log to the console so you know where to open the playground.

  • Save src/index.ts.

Why .js extensions in TypeScript imports?

TypeScript compiles your .ts files into .js files in the dist/ folder. At runtime, Node.js loads the compiled JavaScript, so the import paths need to point to .js files.

✔️ Awesome, I've got everything!

Great. Make sure you have saved src/index.ts before moving on.

ⓧ I'd like to double check the full code

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(`Server ready at: ${url}`);
Run the server and test queries

With the entry point ready, you can compile and start your server for the first time.

  • Start the server by running this command in your terminal:
npm start
  • You should see Server ready at: http://localhost:4000/ in your terminal output.
  • Open http://localhost:4000 in your browser.

This opens the Apollo Explorer, a built-in GraphQL playground where you can write and run queries against your API.

Don't see the server start?

  • Check for TypeScript compilation errors in the terminal output. A typo in an import path is the most common cause.
  • Make sure all four src/ files are saved before running npm start.
  • Confirm your import paths end in .js (not .ts).

Help me debug my Apollo Server startup error.

Now test that your queries work. In the Apollo Explorer's Operation panel (the middle section), clear any existing text and paste this query:

  • Query all books with just their titles by pasting this into the Operation panel and clicking the blue run button:
query GetAllBooks {
  books {
    title
  }
}

You should see a JSON response listing all six book titles in the Response panel on the right.

Why is this powerful?

Notice you only asked for title and the server only returned titles. With a REST API, you would get every field on every book whether you needed it or not. GraphQL lets the client decide exactly what data comes back.

  • Query a single book by ID and include its author's name by running this query:
query GetBookWithAuthor {
  book(id: "1") {
    title
    publishedYear
    author {
      name
    }
  }
}

You should see "The Great Gatsby" with its published year and "F. Scott Fitzgerald" as the author name. This demonstrates nested resolution across a relationship in a single request.

  • Query an author and get their list of books by running this query:
query GetAuthorWithBooks {
  author(id: "3") {
    name
    bio
    books {
      title
      genre
    }
  }
}

You should see George Orwell's name and bio, plus "1984" and "Animal Farm" listed under books. This traverses the graph in the opposite direction.

Test mutations

Mutations let clients create or modify data. You defined two mutations in your schema. Now confirm they work.

  • Run the addBook mutation by pasting this into the Operation panel:
mutation CreateBook {
  addBook(input: {
    title: "Brave New World"
    publishedYear: 1932
    genre: "Dystopian"
    authorId: "3"
  }) {
    id
    title
    author {
      name
    }
  }
}

You should see the new book returned with an id of "7", the title "Brave New World", and George Orwell as the author.

  • Verify the book was added by running the GetAllBooks query again. You should now see seven books in the response.
  • Run the addAuthor mutation to create a new author:
mutation CreateAuthor {
  addAuthor(input: {
    name: "Aldous Huxley"
    bio: "English writer and philosopher"
  }) {
    id
    name
    bio
  }
}

You should see the new author returned with an id of "4" along with their name and bio.

What are input types doing here?

Notice how both mutations accept a single input argument that bundles all the fields together. This is the GraphQL best practice of using purpose-built input types for mutations instead of passing each field as a separate argument.

It keeps your mutation signatures clean and makes it easy to add new optional fields later without changing the argument list.

Seeing null or an error in the response?

  • Check that the authorId in your addBook mutation matches an existing author ID ("1", "2", or "3").
  • Make sure you are using the input wrapper in the mutation arguments, not passing fields directly.
  • If you restarted the server, the in-memory data resets. Previously added books and authors will be gone.

Help me fix my GraphQL mutation that is returning an error or null.

Secret mission

Add Filtering and Pagination

Your books query currently returns every book in the dataset every time. Real-world APIs need to let clients narrow results and page through large collections. Can you add optional arguments to your books query for filtering by author and paginating results?

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • The graphql-bookstore project directory on your Desktop (source code, compiled output, and dependencies).
  • The Apollo Server process running on port 4000.

Keep everything running

No action needed. Choose this if you're still actively building or experimenting with your GraphQL API.

  • Your server continues running at http://localhost:4000 until you close the terminal or stop the process.
  • Your project files remain in the graphql-bookstore folder on your Desktop.

Pause - I'll come back to this later

Stop the running server to free up port 4000. Your project files stay on disk so you can restart anytime.

  • Switch to the terminal where your server is running.
  • Press Ctrl+C to stop the server process.
  • To resume later, open a terminal in the graphql-bookstore folder and run npm start.

Delete - I don't want to use this again

Remove all project files and stop the server. This frees up disk space and releases port 4000.

  • Switch to the terminal where your server is running and press Ctrl+C to stop it.
  • Delete the project folder by running these commands (macOS/Linux):
cd ~/Desktop
rm -rf graphql-bookstore

On Windows, run this instead:

cd ~/Desktop
rmdir /s /q graphql-bookstore
  • Confirm the folder is gone by running ls (macOS/Linux) or dir (Windows) in your Desktop directory. You should no longer see graphql-bookstore listed.

That's a Wrap!

That's a Wrap!

Nice work! You just built a fully functional GraphQL API from scratch using Apollo Server and TypeScript.

You've learned how to:

  • Designed a GraphQL schema using SDL with object types, input types, queries, and mutations that model a real domain as a graph.
  • Implemented resolvers including nested resolvers for relationships between Books and Authors, connecting your schema to data.
  • Tested your API interactively using the Apollo Explorer, verifying queries, mutations, and nested relationship resolution.
  • Secret Mission: Added filtering and pagination arguments to the books query, making your API production-ready with client-driven data shaping.

Ready to quiz yourself?