Build Your First GraphQL API: Queries, Mutations, and Why It Clicks
Build a GraphQL API from scratch with Apollo Server, Node.js, and a database. Learn schemas, resolvers, queries, mutations, and subscriptions.
REST APIs have a problem that nobody talks about until they've built enough of them: you either fetch too much data or too little. Your /users/42 endpoint returns 30 fields when the mobile app only needs 3. Your /posts endpoint doesn't include author info, so now you need a second request. You end up with /posts?include=author&fields=title,body and at that point you're basically inventing a query language on top of REST.
GraphQL is that query language, designed from the start. The client asks for exactly what it needs, and the server returns exactly that. No more, no less.
Let's build a GraphQL API from scratch. By the end, you'll have a working bookshelf API with queries, mutations, error handling, and a real database.
What GraphQL Actually Solves
With REST, the server decides the shape of the response. With GraphQL, the client decides. Here's the difference in practice:
REST approach -- multiple requests, over-fetching:GET /books/1 -> { id, title, author_id, isbn, pages, publisher, ... }
GET /authors/5 -> { id, name, bio, born, nationality, ... }
GET /books/1/reviews -> [{ id, text, rating, user_id, ... }, ...]
Three requests. Most of the data returned is unused.
GraphQL approach -- one request, exact data:query {
book(id: "1") {
title
author {
name
}
reviews {
rating
text
}
}
}
One request. You get exactly title, author.name, reviews.rating, and reviews.text. Nothing else.
Setting Up Apollo Server
Apollo Server is the most popular GraphQL server for Node.js. Let's set up the project:
mkdir graphql-bookshelf
cd graphql-bookshelf
npm init -y
npm install @apollo/server graphql
npm install -D typescript @types/node tsx
Create a tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "./dist",
"strict": true,
"esModuleInterop": true
}
}
Update package.json:
{
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js"
}
}
Defining the Schema
GraphQL uses a strongly-typed schema to define what data is available and how it's structured. Create your schema:
// src/schema.ts
export const typeDefs = #graphql
type Book {
id: ID!
title: String!
author: Author!
isbn: String
pages: Int
genre: Genre!
rating: Float
reviews: [Review!]!
createdAt: String!
}
type Author {
id: ID!
name: String!
bio: String
books: [Book!]!
}
type Review {
id: ID!
text: String!
rating: Int!
reviewer: String!
createdAt: String!
}
enum Genre {
FICTION
NON_FICTION
SCIENCE_FICTION
FANTASY
MYSTERY
BIOGRAPHY
TECHNICAL
}
type Query {
books(genre: Genre, limit: Int): [Book!]!
book(id: ID!): Book
authors: [Author!]!
author(id: ID!): Author
searchBooks(query: String!): [Book!]!
}
type Mutation {
addBook(input: AddBookInput!): Book!
addReview(bookId: ID!, input: AddReviewInput!): Review!
deleteBook(id: ID!): Boolean!
}
input AddBookInput {
title: String!
authorId: ID!
isbn: String
pages: Int
genre: Genre!
}
input AddReviewInput {
text: String!
rating: Int!
reviewer: String!
}
;
Key things to notice:
!means non-nullable.String!means this field always has a value.[Book!]!means a non-null array of non-null books (the array itself is never null, and no element in it is null).Querytype defines what you can read.Mutationtype defines what you can write.inputtypes are used for mutation arguments -- they keep the schema clean.enumtypes restrict values to a defined set.
Writing Resolvers
Resolvers are functions that actually fetch the data for each field. Let's start with in-memory data and build up:
// src/data.ts
export interface Book {
id: string;
title: string;
authorId: string;
isbn?: string;
pages?: number;
genre: string;
rating?: number;
createdAt: string;
}
export interface Author {
id: string;
name: string;
bio?: string;
}
export interface Review {
id: string;
bookId: string;
text: string;
rating: number;
reviewer: string;
createdAt: string;
}
export const authors: Author[] = [
{ id: "a1", name: "Frank Herbert", bio: "American science fiction author" },
{ id: "a2", name: "Ursula K. Le Guin", bio: "American novelist and short story writer" },
{ id: "a3", name: "Andy Weir", bio: "American novelist known for hard science fiction" },
];
export const books: Book[] = [
{ id: "b1", title: "Dune", authorId: "a1", isbn: "978-0441013593", pages: 688, genre: "SCIENCE_FICTION", rating: 4.7, createdAt: "2026-01-15" },
{ id: "b2", title: "The Left Hand of Darkness", authorId: "a2", isbn: "978-0441478125", pages: 304, genre: "SCIENCE_FICTION", rating: 4.3, createdAt: "2026-02-01" },
{ id: "b3", title: "The Martian", authorId: "a3", isbn: "978-0553418026", pages: 369, genre: "SCIENCE_FICTION", rating: 4.6, createdAt: "2026-02-20" },
{ id: "b4", title: "A Wizard of Earthsea", authorId: "a2", isbn: "978-0547722023", pages: 183, genre: "FANTASY", rating: 4.4, createdAt: "2026-03-01" },
];
export const reviews: Review[] = [
{ id: "r1", bookId: "b1", text: "A masterpiece of world-building.", rating: 5, reviewer: "SciFiFan42", createdAt: "2026-03-10" },
{ id: "r2", bookId: "b1", text: "Dense but rewarding.", rating: 4, reviewer: "BookWorm", createdAt: "2026-03-12" },
{ id: "r3", bookId: "b3", text: "Couldn't put it down!", rating: 5, reviewer: "SpaceNerd", createdAt: "2026-03-15" },
];
let nextId = 100;
export const generateId = () => String(++nextId);
Now the resolvers:
// src/resolvers.ts
import { books, authors, reviews, generateId } from "./data.js";
import type { Book, Author, Review } from "./data.js";
import { GraphQLError } from "graphql";
export const resolvers = {
Query: {
books: (_: unknown, args: { genre?: string; limit?: number }) => {
let result = [...books];
if (args.genre) {
result = result.filter(b => b.genre === args.genre);
}
if (args.limit) {
result = result.slice(0, args.limit);
}
return result;
},
book: (_: unknown, args: { id: string }) => {
return books.find(b => b.id === args.id) || null;
},
authors: () => authors,
author: (_: unknown, args: { id: string }) => {
return authors.find(a => a.id === args.id) || null;
},
searchBooks: (_: unknown, args: { query: string }) => {
const q = args.query.toLowerCase();
return books.filter(b => b.title.toLowerCase().includes(q));
},
},
Mutation: {
addBook: (_: unknown, args: { input: Omit<Book, "id" | "createdAt" | "rating"> }) => {
const author = authors.find(a => a.id === args.input.authorId);
if (!author) {
throw new GraphQLError("Author not found", {
extensions: { code: "NOT_FOUND" },
});
}
const newBook: Book = {
id: generateId(),
...args.input,
createdAt: new Date().toISOString().split("T")[0],
};
books.push(newBook);
return newBook;
},
addReview: (_: unknown, args: { bookId: string; input: Omit<Review, "id" | "bookId" | "createdAt"> }) => {
const book = books.find(b => b.id === args.bookId);
if (!book) {
throw new GraphQLError("Book not found", {
extensions: { code: "NOT_FOUND" },
});
}
if (args.input.rating < 1 || args.input.rating > 5) {
throw new GraphQLError("Rating must be between 1 and 5", {
extensions: { code: "BAD_USER_INPUT" },
});
}
const newReview: Review = {
id: generateId(),
bookId: args.bookId,
...args.input,
createdAt: new Date().toISOString().split("T")[0],
};
reviews.push(newReview);
return newReview;
},
deleteBook: (_: unknown, args: { id: string }) => {
const index = books.findIndex(b => b.id === args.id);
if (index === -1) return false;
books.splice(index, 1);
return true;
},
},
// Field-level resolvers for relationships
Book: {
author: (parent: Book) => {
return authors.find(a => a.id === parent.authorId);
},
reviews: (parent: Book) => {
return reviews.filter(r => r.bookId === parent.id);
},
},
Author: {
books: (parent: Author) => {
return books.filter(b => b.authorId === parent.id);
},
},
};
The field-level resolvers (Book.author, Book.reviews, Author.books) are what make relationships work. When a query asks for book.author, GraphQL calls the Book.author resolver with the book as the parent.
Wiring It Up
// src/index.ts
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 running at ${url});
Run it:
npm run dev
Open http://localhost:4000 in your browser. Apollo Server ships with Apollo Sandbox, an interactive query editor. This is your playground.
Making Queries
Try these in the Sandbox:
# Get all books with their authors
query {
books {
title
genre
rating
author {
name
}
}
}
# Get a specific book with reviews
query {
book(id: "b1") {
title
pages
reviews {
text
rating
reviewer
}
}
}
# Filter by genre
query {
books(genre: FANTASY) {
title
author {
name
}
}
}
# Get an author with all their books
query {
author(id: "a2") {
name
bio
books {
title
genre
}
}
}
Notice how each query returns exactly the fields requested. Ask for title only, and that's all you get. Ask for nested relationships three levels deep, and GraphQL resolves them automatically.
Running Mutations
# Add a new book
mutation {
addBook(input: {
title: "Project Hail Mary"
authorId: "a3"
isbn: "978-0593135204"
pages: 496
genre: SCIENCE_FICTION
}) {
id
title
author {
name
}
}
}
# Add a review
mutation {
addReview(bookId: "b3", input: {
text: "Andy Weir does it again!"
rating: 5
reviewer: "MarsExplorer"
}) {
id
text
rating
}
}
Connecting to a Database
In-memory data is fine for learning, but let's connect to SQLite with Prisma for something more realistic:
npm install @prisma/client
npm install -D prisma
npx prisma init --datasource-provider sqlite
Define your schema in prisma/schema.prisma:
model Book {
id String @id @default(cuid())
title String
isbn String?
pages Int?
genre String
rating Float?
author Author @relation(fields: [authorId], references: [id])
authorId String
reviews Review[]
createdAt DateTime @default(now())
}
model Author {
id String @id @default(cuid())
name String
bio String?
books Book[]
}
model Review {
id String @id @default(cuid())
text String
rating Int
reviewer String
book Book @relation(fields: [bookId], references: [id])
bookId String
createdAt DateTime @default(now())
}
npx prisma migrate dev --name init
Update your resolvers to use Prisma:
// src/resolvers.ts (database version)
import { PrismaClient } from "@prisma/client";
import { GraphQLError } from "graphql";
const prisma = new PrismaClient();
export const resolvers = {
Query: {
books: async (_: unknown, args: { genre?: string; limit?: number }) => {
return prisma.book.findMany({
where: args.genre ? { genre: args.genre } : undefined,
take: args.limit || undefined,
orderBy: { createdAt: "desc" },
});
},
book: async (_: unknown, args: { id: string }) => {
return prisma.book.findUnique({ where: { id: args.id } });
},
authors: async () => prisma.author.findMany(),
author: async (_: unknown, args: { id: string }) => {
return prisma.author.findUnique({ where: { id: args.id } });
},
searchBooks: async (_: unknown, args: { query: string }) => {
return prisma.book.findMany({
where: { title: { contains: args.query } },
});
},
},
Mutation: {
addBook: async (_: unknown, args: { input: any }) => {
const author = await prisma.author.findUnique({
where: { id: args.input.authorId },
});
if (!author) {
throw new GraphQLError("Author not found", {
extensions: { code: "NOT_FOUND" },
});
}
return prisma.book.create({ data: args.input });
},
addReview: async (_: unknown, args: { bookId: string; input: any }) => {
return prisma.review.create({
data: { ...args.input, bookId: args.bookId },
});
},
deleteBook: async (_: unknown, args: { id: string }) => {
try {
await prisma.book.delete({ where: { id: args.id } });
return true;
} catch {
return false;
}
},
},
Book: {
author: async (parent: { authorId: string }) => {
return prisma.author.findUnique({ where: { id: parent.authorId } });
},
reviews: async (parent: { id: string }) => {
return prisma.review.findMany({ where: { bookId: parent.id } });
},
},
Author: {
books: async (parent: { id: string }) => {
return prisma.book.findMany({ where: { authorId: parent.id } });
},
},
};
Error Handling
GraphQL has a built-in error system. Throw GraphQLError with error codes:
import { GraphQLError } from "graphql";
// Not found
throw new GraphQLError("Book not found", {
extensions: { code: "NOT_FOUND", bookId: args.id },
});
// Validation error
throw new GraphQLError("Rating must be between 1 and 5", {
extensions: { code: "BAD_USER_INPUT", field: "rating" },
});
// Authentication error
throw new GraphQLError("You must be logged in", {
extensions: { code: "UNAUTHENTICATED" },
});
The client receives structured errors alongside partial data (if some fields resolved successfully):
{
"data": { "book": null },
"errors": [{
"message": "Book not found",
"extensions": { "code": "NOT_FOUND", "bookId": "b999" }
}]
}
The N+1 Problem
There's a gotcha. When you query all books with their authors:
query {
books {
title
author { name }
}
}
The books resolver runs once (1 query). Then for each of the N books, the Book.author resolver runs (N queries). That's N+1 database queries for what should be one.
The standard fix is DataLoader:
npm install dataloader
import DataLoader from "dataloader";
// Create a batch loader for authors
const authorLoader = new DataLoader(async (authorIds: readonly string[]) => {
const authors = await prisma.author.findMany({
where: { id: { in: [...authorIds] } },
});
// Return in the same order as the input IDs
return authorIds.map(id => authors.find(a => a.id === id));
});
// In the resolver
Book: {
author: (parent: { authorId: string }) => authorLoader.load(parent.authorId),
}
DataLoader batches individual loads into a single database query. Instead of N separate queries, it makes one query for all N authors at once.
Common Mistakes
Over-exposing your data model. Your GraphQL schema should represent what clients need, not a mirror of your database tables. Design your schema from the client's perspective. Not handling null properly. Think carefully about which fields can be null. A! in the wrong place means a null value in one field will null out the entire parent object.
Allowing unbounded queries. Without limits, a client can request every book with every review with every author -- crashing your server. Always set default and maximum limits.
Ignoring subscriptions for real-time needs. If your app needs real-time updates (chat, notifications, live data), GraphQL subscriptions are the natural fit. Don't bolt on a separate WebSocket system.
Skipping input validation. Just because GraphQL enforces types doesn't mean the data is valid. A String! can still be empty. An Int! can still be negative. Validate in your resolvers.
What's Next
You now have a working GraphQL API with type safety, relationships, and error handling. Here's where to go from there:
- Authentication -- Add JWT-based auth using Apollo Server context
- Pagination -- Implement cursor-based pagination for large datasets
- Caching -- Use Apollo Client's normalized cache on the frontend
- Schema stitching -- Combine multiple GraphQL APIs into one
- Code generation -- Use GraphQL Code Generator for type-safe resolvers
- Rate limiting -- Protect your API from expensive queries with depth and complexity limits
For more backend tutorials, check out CodeUp.