March 27, 202613 min read

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.

graphql api nodejs backend tutorial
Ad 336x280

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).

  • Query type defines what you can read. Mutation type defines what you can write.

  • input types are used for mutation arguments -- they keep the schema clean.

  • enum types 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
GraphQL clicks once you build something with it. The schema is the contract between frontend and backend, and the tooling around it (type safety, auto-generated docs, interactive explorers) makes API development feel more structured than REST ever did.

For more backend tutorials, check out CodeUp.

Ad 728x90