Skip to main content

GraphQL Zero to Hero: build your first API and client

GraphQL is a language and runtime for asking an API for data. Instead of the server deciding one fixed response shape for each endpoint, the client asks for the fields it needs from a typed contract called a schema.

For example, a project screen may need a project, its owner, and each task's title and status. GraphQL can express that data shape in one request:

query ProjectScreen($id: ID!) {
project(id: $id) {
name
owner { name }
tasks { title status }
}
}

This guide takes you from the idea to a working JavaScript GraphQL API and client. You will learn the parts that matter first, then the habits needed to use GraphQL safely in a real app.

What problem does GraphQL solve?​

Imagine building a task page with a REST API. Depending on the API design, the browser might need data from several endpoints:

GET /projects/42
GET /projects/42/tasks
GET /users/7

The first response might also contain fields the page does not display. This is often called:

  • Under-fetching: the first response is not enough, so the client needs more requests.
  • Over-fetching: the response contains far more data than the screen needs.

GraphQL gives the client a way to describe the response shape. Usually, an API exposes one GraphQL endpoint such as /graphql; the operation document says what to read or change.

GraphQL is not automatically better than REST. A simple GET /health or a small resource API can be clearer with REST. GraphQL becomes compelling when many clients need different views of connected data, or when a screen needs several related resources.

The mental model​

Think of a GraphQL API as a typed map of your application's data:

Schema      = the map and its rules
Query = a read request
Mutation = a write request
Subscription = a live stream of events
Resolver = JavaScript code that obtains one field's value

The schema is the source of truth. It tells clients which fields exist, what arguments they accept, and which values they return. Tooling can read the schema to provide autocomplete, documentation, validation, and generated types.

Learn the syntax in five minutes​

Here is a small schema for books:

type Book {
id: ID!
title: String!
author: String!
publishedYear: Int
}

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

Book is an object type. String, Int, Boolean, ID, and Float are built-in scalar types. ! means the value cannot be null at that position:

Schema typeMeaning
StringA string or null
String!A string is required
[Book]The list or its individual entries may be null
[Book!]!A required list containing required books

A client selects fields, not types directly:

query {
books {
id
title
}
}

The response has the same nesting as the selection:

{
"data": {
"books": [
{ "id": "1", "title": "The Pragmatic Programmer" }
]
}
}

You cannot request authorName if the schema defines author. The GraphQL server validates the operation before it runs it.

Your first GraphQL server​

Create a Node.js project and install GraphQL Yoga, a lightweight GraphQL server:

npm init -y
npm install graphql graphql-yoga

Create server.mjs with a complete in-memory API:

import { createServer } from 'node:http';
import { createSchema, createYoga } from 'graphql-yoga';

const books = [
{ id: '1', title: 'The Pragmatic Programmer', author: 'Andrew Hunt', publishedYear: 1999 },
{ id: '2', title: 'Clean Code', author: 'Robert C. Martin', publishedYear: 2008 },
];

const typeDefs = /* GraphQL */ `
type Book {
id: ID!
title: String!
author: String!
publishedYear: Int
}

input AddBookInput {
title: String!
author: String!
publishedYear: Int
}

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

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

const resolvers = {
Query: {
books: () => books,
book: (_parent, { id }) => books.find((book) => book.id === id) ?? null,
},
Mutation: {
addBook: (_parent, { input }) => {
const book = { id: crypto.randomUUID(), ...input };
books.push(book);
return book;
},
},
};

const schema = createSchema({ typeDefs, resolvers });
const yoga = createYoga({ schema, graphqlEndpoint: '/graphql', graphiql: true });

createServer(yoga).listen(4000, () => {
console.log('GraphQL API: http://localhost:4000/graphql');
});

Start it with node server.mjs, then open http://localhost:4000/graphql. GraphiQL is an interactive explorer: write an operation, run it, inspect the schema, and use autocomplete. It is the fastest place to practise while learning.

What the server code does​

typeDefs defines the public contract. resolvers are the JavaScript functions that fulfil the contract. A resolver receives roughly (parent, arguments, context, info):

  • parent is the value returned by the containing field.
  • arguments contains values supplied by the client, such as { id: "1" }.
  • context holds request-scoped dependencies such as the current user, database, and loaders.
  • info contains execution details; most application resolvers do not need it.

The lab project follows this exact structure: its schema defines Query, Mutation, and Subscription roots, while resolver functions read the database and enforce permissions.

Queries: read exactly what you need​

Try these operations in GraphiQL.

Query a collection​

query AllBooks {
books {
id
title
author
}
}

Query one record with a variable​

Variables keep changing values separate from the reusable document. Do not build query strings by interpolating user input.

query BookById($id: ID!) {
book(id: $id) {
id
title
publishedYear
}
}

Send the variables separately:

{ "id": "1" }

Use aliases and fragments​

An alias changes a key in the response. A fragment reuses a selection set; it does not make another network request.

query CompareBooks($first: ID!, $second: ID!) {
firstBook: book(id: $first) { ...BookCard }
secondBook: book(id: $second) { ...BookCard }
}

fragment BookCard on Book {
id
title
author
}

Mutations: change data deliberately​

Reads belong in Query; creation, updates, and deletes belong in Mutation. A mutation can take an input object and request the fields the UI needs after the change:

mutation AddBook($input: AddBookInput!) {
addBook(input: $input) {
id
title
author
}
}

Variables:

{
"input": {
"title": "Designing Data-Intensive Applications",
"author": "Martin Kleppmann",
"publishedYear": 2017
}
}

Use input types for structured mutation data. They give clients validation and autocomplete, and keep mutation signatures from becoming long lists of arguments.

GraphQL over HTTP: no client library required​

GraphQL is commonly transported over HTTP POST. The body contains query, variables, and optionally operationName when a document has multiple operations.

const query = `
query BookById($id: ID!) {
book(id: $id) { id title author }
}
`;

const response = await fetch('http://localhost:4000/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/graphql-response+json, application/json',
},
body: JSON.stringify({
query,
variables: { id: '1' },
operationName: 'BookById',
}),
});

const result = await response.json();

if (result.errors) {
console.error(result.errors);
}

console.log(result.data?.book);

The Angular GraphQL Lab deliberately begins with this plain HTTP model. Apollo Angular is useful later for caching, pagination, optimistic updates, and subscriptions, but it is not a requirement for GraphQL.

Understand the response envelope​

A GraphQL response can contain both data and errors:

{
"data": { "book": null },
"errors": [
{ "message": "Book not found", "path": ["book"] }
]
}

Therefore, an HTTP 200 status does not guarantee every requested field succeeded. Always check the GraphQL errors array as well as network errors. A non-null field that fails can cause GraphQL to return null for its parent fields; design nullability carefully.

Add relationships and field resolvers​

Real data is connected. If an author becomes its own resource rather than a text field, model that relationship like this (replacing the earlier flat author: String! field):

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

type Book {
id: ID!
title: String!
author: Author!
}

type Query {
author(id: ID!): Author
}

Now the client can ask for a graph:

query AuthorShelf($id: ID!) {
author(id: $id) {
name
books { title }
}
}

Add resolvers for relationship fields. Do not assume GraphQL magically makes database access efficient: asking for 20 books and fetching an author separately for every book can create the N+1 query problem. In production, batch related database reads with a request-scoped loader (commonly DataLoader) or fetch relations efficiently in the repository layer.

Subscriptions: receive updates as they happen​

Queries read once and mutations write once. A subscription keeps a connection open so the server can push matching events, for example when a task is created:

type Subscription {
taskCreated: Task!
}
subscription OnTaskCreated {
taskCreated { id title status }
}

Subscriptions need a server transport and lifecycle management in addition to the schema. The Angular GraphQL Lab uses GraphQL-over-SSE for its task events. Learn queries and mutations first; add subscriptions only when the product genuinely needs live updates.

Production rules that prevent expensive mistakes​

GraphQL gives clients expressive power, so the server must enforce boundaries.

  1. Authenticate the request once and put the verified user in context.
  2. Authorize in resolvers or the service layer for every protected field and mutation.
  3. Validate mutation input and return helpful, structured GraphQL errors.
  4. Set depth, complexity, pagination, and timeout limits so one query cannot exhaust the API.
  5. Paginate large lists. Cursor pagination (edges, node, pageInfo) is a strong default when records can change while a user is paging.
  6. Avoid exposing internal database fields merely because a resolver can read them.
  7. Log the operation name, duration, errors, and cost—never sensitive variables or tokens.
  8. Use HTTPS, configure CORS narrowly, and consider disabling public introspection in sensitive production environments according to your security policy.

The lab's TaskHub schema demonstrates several of these ideas: mutations require a user in the request context, check project ownership, use input objects, and return cursor pagination data.

How to evolve a GraphQL API safely​

GraphQL schemas are designed to evolve. Add a new field, make it available, and let clients adopt it. Do not immediately delete a field used by existing clients.

Instead, deprecate it:

type Book {
title: String!
name: String @deprecated(reason: "Use title instead")
}

Monitor usage, migrate clients, then remove the deprecated field in a planned breaking change. Avoid changing a nullable field to non-null, changing a field's type, or silently changing a field's meaning; those can break clients even though the endpoint URL never changes.

A practical learning plan​

Follow this order to build real understanding rather than only memorizing syntax:

  1. Run the server above and explore it in GraphiQL.
  2. Write queries with fields, arguments, variables, aliases, and fragments.
  3. Add an input type and mutation; observe the data and errors response envelope.
  4. Add one relationship and inspect the resolver calls.
  5. Introduce a real database, request context, authentication, and authorization.
  6. Learn cursor pagination and batch loading before exposing large relational lists.
  7. Add a client. Start with fetch or Angular HttpClient; add Apollo only when its cache, fetch policies, pagination helpers, or subscription support solves a real need.
  8. Add generated client types after the schema and operations are stable enough to share.

The key idea to keep​

GraphQL is not "one endpoint that returns everything." It is a typed agreement: the server defines what is possible, and the client selects the exact fields needed for one operation. Build a small schema, write resolvers that are secure and efficient, and grow the contract with care. That is the path from a first query to a GraphQL API that stays useful as the product grows.