GraphQL APIs in Node: A Practical Introduction

GraphQL solves a problem that becomes apparent as soon as you model interconnected data: REST endpoints often force clients to make multiple requests to assemble the information they need, and frequently return far more than the client actually uses. GraphQL gives you a single endpoint where the client specifies its exact data shape, simplifying UI code and reducing payload size.

We'll walk through building a GraphQL API in Node.js with Apollo Server. Along the way, we'll cover the two core building blocks of any GraphQL API: schemas (the data contract) and resolvers (the implementation of that contract). By the end, you'll have a working CRUD API backed by an in-memory data store.

A "book highlights" API

Our example API manages memorable passages from reading. It supports the standard CRUD operations: creating a highlight, reading a single highlight or listing all highlights, updating a highlight's content, and deleting a highlight.

You'll need Node v8.x or later and basic command-line proficiency. Start by creating a project directory, initializing npm, and installing the following dependencies:

# make the new directory
mkdir highlights-api
# change into the directory
cd highlights-api
# initiate a new node project
npm init -y
# install the project dependencies
npm install apollo-server graphql
# install the development dependencies
npm install nodemon --save-dev

Three packages do the work here. apollo-server provides the GraphQL server itself, with middleware available for Express, hapi, Fastify, and Koa if you prefer one of those. The graphql package is a required peer dependency containing the core GraphQL implementation. nodemon watches your project files and restarts the server automatically during development.

Create the application's root file index.js with an initial test message, then add a npm start script that runs the file via nodemon:

console.log("📚 Hello Highlights");

Running npm start should print 📚 Hello Highlights in your terminal.

Defining the schema

A GraphQL schema is a written specification of what the API can return and what interactions it supports. Nothing outside the schema is accessible, which enforces a strict API contract from the start. The schema's fundamental building blocks are object types, which are composed from five built-in scalar types: String, Boolean, Int, Float, and ID.

Create a schema.js file and import the gql template tag to begin writing schema syntax:

const { gql } = require('apollo-server');

const typeDefs = gql`
  # The schema will go here
`;

module.exports = typeDefs;

Our domain object, Highlight, requires an ID (represented by the ID scalar), content, title, and author. Server-side, we'll also make several fields non-nullable. GraphQL marks required fields with an exclamation point:

const typeDefs = gql`
  type Highlight {
    id: ID
    content: String
    title: String
    author: String
  }
`;

A successful id: ID! and content: String! pairing with optional title and author fields rounds out the type. Required fields appear inside request shapes (we'll see that shortly) and must be returned in responses.

const typeDefs = gql`
  type Highlight {
    id: ID!
    content: String!
    title: String
    author: String
  }
`;

Object types alone don't get clients data. The schema must expose a Query type that describes data fetching entry points. For our highlights API, that means one query returning an array (written as [Highlight]) and a second that takes an id argument for retrieving a single highlight:

const typeDefs = gql`
  type Highlight {
    id: ID!
    content: String!
    title: String
    author: String
  }
  type Query {
    highlights: [Highlight]!
    highlight(id: ID!): Highlight
  }
`;
const {ApolloServer } = require('apollo-server');
const typeDefs = require('./schema');

const server = new ApolloServer({ typeDefs });

server.listen().then(({ url }) => {
  console.log(`📚 Highlights server ready at ${url}`);
});

If your server is still running, nodemon picks up the changes without intervention. Visit the printed URL (typically http://localhost:4000) and you'll land in the GraphQL Playground, an interactive tool for exercising your API.

Resolvers: the code behind the schema

With the schema alone, queries return no data. The missing piece is a resolver function for each query. In Apollo Server (like all GraphQL implementations), resolvers connect each query to actual data. In a real project that could be a database, a file system, or a third-party API, but here we'll keep an in-memory array as our data store. Add these initial highlights to index.js:

let highlights = [
  {
    id: '1',
    content: 'One day I will find the right words, and they will be simple.',
    title: 'Dharma Bums',
    author: 'Jack Kerouac'
  },
  {
    id: '2',
    content: 'In the limits of a situation there is humor, there is grace, and everything else.',
    title: 'Arbitrary Stupid Goal',
    author: 'Tamara Shopsin'
  }
]

Queries retrieve data without modifying anything. The two queries already exist in the schema; now implement them in JavaScript:

const resolvers = {
Query: {
highlights: () => highlights,
highlight: (parent, args) => highlights.find(h => h.id === args.id)
}
}

Then register these resolvers with the Apollo Server instance:

const server = new ApolloServer({ typeDefs, resolvers });

The resolver for highlight receives two parameters. The first, parent (sometimes called root), carries context from higher levels of the query — unused at our top level but always present. The second, args, contains the arguments from the schema, such as the highlight id we defined.

Every GraphQL query specifies which fields to return. A highlights query can request only certain fields (the response shape mirrors the request). Our two useful patterns: requesting all fields, or trimming the response if clients only need a title and author:

query {
  highlights {
    id
    content
    title
    author
  }
}
query {
  highlights {
    title
    author
  }
}

With a book highlights list, retrieving full content isn't wasteful when reading summaries. More load differs again — fetching a single highlight by ID makes a good controlled comparison:

query {
  highlight(id: "1") {
    content
  }
}

Mutations: writing with GraphQL

Any GraphQL operation that modifies data is a mutation. Unlike queries, they may intentionally change server-side state — but like queries, they return an object reflecting the outcome.

Our API needs mutations to create, update, and delete highlights. First, extend the schema by adding a Mutation type alongside Query:

type Mutation {
  newHighlight (content: String! title: String author: String): Highlight!
  updateHighlight(id: ID! content: String!): Highlight!
  deleteHighlight(id: ID!): Highlight!
}

Three new operations become available to clients: newHighlight takes required content plus optional title and author, returning a fresh Highlight. updateHighlight requires an id and updates content; deleteHighlight takes an id and returns the removed item.

Each resolver executes the corresponding mutation logic and returns the updated pieces of our array:

const resolvers = {
  Query: {
    highlights: () => highlights,
    highlight: (parent, args) => {
      return highlights.find(highlight => highlight.id === args.id);
    }
  },
  Mutation: {
    newHighlight: (parent, args) => {
      const highlight = {
        id: String(highlights.length + 1),
        title: args.title || '',
        author: args.author || '',
        content: args.content
      };
      highlights.push(highlight);
      return highlight;
    },
    updateHighlight: (parent, args) => {
      const index = highlights.findIndex(highlight => highlight.id === args.id);
      const highlight = {
        id: args.id,
        content: args.content,
        author: highlights[index].author,
        title: highlights[index].title
      };
      highlights[index] = highlight;
      return highlight;
    },
    deleteHighlight: (parent, args) => {
      const deletedHighlight = highlights.find(
        highlight => highlight.id === args.id
      );
      highlights = highlights.filter(highlight => highlight.id !== args.id);
      return deletedHighlight;
    }
  }
};

Mutation syntax resembles query syntax: name your operation, pass required arguments in parentheses, and select the return fields in the response — though, of course, changes will persist to the highlights array served to all subsequent requests.

Adding a highlight fields takes:

mutation {
  newHighlight(author: "Adam Scott" title: "JS Everywhere" content: "GraphQL is awesome") {
    id
    author
    title
    content
  }
}

Where to go from here

We now have a full server-side implementation of CRUD operations on an in-memory dataset: queries for reading data, and mutations for performing write operations, all through a schema-correct API.

This foundation opens several directions for further exploration: