Build a GraphQL API and React Client: An End‑to‑End Tutorial

Learn GraphQL with React and Apollo Client by building a full stack app with queries, mutations, caching, and pagination—step by step.

ASOasis
10 min read
Build a GraphQL API and React Client: An End‑to‑End Tutorial

Image used for representation purposes only.

Overview

GraphQL lets your client ask for exactly the data it needs and nothing more. In this tutorial, you’ll build a small, production‑style stack: a GraphQL API (Node.js) and a React client (TypeScript) using Apollo Client. Along the way you’ll learn queries, mutations, fragments, caching, optimistic UI, and cursor‑based pagination.

By the end you’ll have a functioning end‑to‑end app you can extend for real projects.

Prerequisites

  • Node.js 18+ and npm (or pnpm/yarn)
  • Basic React + TypeScript
  • Familiarity with REST is helpful, but not required

What we’re building

A minimal “Books” app:

  • List books with cursor pagination
  • Add a new book (with optimistic UI)
  • Client‑side caching and fragments

Step 1: Project setup

Create a monorepo‑ish layout with two folders: api and web.

mkdir graphql-react-tutorial && cd $_
mkdir api web

Web (React + Vite + TypeScript)

cd web
npm create vite@latest . -- --template react-ts
npm i @apollo/client graphql
npm i -D @types/node

API (Node + Apollo Server)

cd ../api
npm init -y
npm i @apollo/server graphql cors body-parser express
npm i -D ts-node typescript @types/express @types/node nodemon
npx tsc --init

Add scripts to api/package.json:

{
  "scripts": {
    "dev": "nodemon --watch src --exec ts-node src/server.ts"
  }
}

Step 2: Define the GraphQL schema

Create api/src/schema.ts with a simple Book type and relay‑style pagination.

export const typeDefs = `#graphql
  type Book { id: ID!, title: String!, author: String! }

  type BookEdge { cursor: ID!, node: Book! }
  type PageInfo { endCursor: ID, hasNextPage: Boolean! }
  type BookConnection { edges: [BookEdge!]!, pageInfo: PageInfo! }

  type Query {
    books(first: Int = 5, after: ID): BookConnection!
    book(id: ID!): Book
  }

  type Mutation {
    addBook(title: String!, author: String!): Book!
    deleteBook(id: ID!): Boolean!
  }
`;

Step 3: Implement resolvers and server

Create api/src/server.ts with an in‑memory data store.

import express from 'express';
import cors from 'cors';
import bodyParser from 'body-parser';
import { ApolloServer } from '@apollo/server';
import { expressMiddleware } from '@apollo/server/express4';
import { typeDefs } from './schema';

type Book = { id: string; title: string; author: string };

const seed: Book[] = [
  { id: '1', title: 'Clean Architecture', author: 'Robert C. Martin' },
  { id: '2', title: 'The Pragmatic Programmer', author: 'Andrew Hunt' },
  { id: '3', title: 'Refactoring', author: 'Martin Fowler' },
  { id: '4', title: 'Designing Data-Intensive Applications', author: 'Martin Kleppmann' },
  { id: '5', title: 'You Don’t Know JS Yet', author: 'Kyle Simpson' },
  { id: '6', title: 'Effective TypeScript', author: 'Dan Vanderkam' }
];

let books: Book[] = [...seed];
let nextId = books.length + 1;

const resolvers = {
  Query: {
    book: (_: unknown, { id }: { id: string }) => books.find(b => b.id === id) || null,
    books: (_: unknown, { first = 5, after }: { first: number; after?: string }) => {
      const startIndex = after ? books.findIndex(b => b.id === after) + 1 : 0;
      const slice = books.slice(startIndex, startIndex + first);
      const edges = slice.map(b => ({ cursor: b.id, node: b }));
      const endCursor = edges.length ? edges[edges.length - 1].cursor : null;
      const hasNextPage = startIndex + first < books.length;
      return { edges, pageInfo: { endCursor, hasNextPage } };
    },
  },
  Mutation: {
    addBook: (_: unknown, { title, author }: { title: string; author: string }) => {
      const book: Book = { id: String(nextId++), title, author };
      books.unshift(book);
      return book;
    },
    deleteBook: (_: unknown, { id }: { id: string }) => {
      const before = books.length;
      books = books.filter(b => b.id !== id);
      return books.length < before;
    }
  }
};

async function start() {
  const server = new ApolloServer({ typeDefs, resolvers });
  await server.start();

  const app = express();
  app.use(cors());
  app.use('/graphql', bodyParser.json(), expressMiddleware(server));

  const PORT = 4000;
  app.listen(PORT, () => {
    console.log(`GraphQL running at http://localhost:${PORT}/graphql`);
  });
}

start();

Run the API:

npm run dev

You should see the server log the GraphQL endpoint.

Step 4: Wire up Apollo Client in React

In web/src/main.tsx, wrap your app with ApolloProvider.

import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.tsx'
import { ApolloClient, InMemoryCache, ApolloProvider, HttpLink } from '@apollo/client'
import { relayStylePagination } from '@apollo/client/utilities'

const client = new ApolloClient({
  link: new HttpLink({ uri: 'http://localhost:4000/graphql' }),
  cache: new InMemoryCache({
    typePolicies: {
      Query: {
        fields: {
          // Merge pages for books(first, after)
          books: relayStylePagination(),
        }
      }
    }
  })
})

ReactDOM.createRoot(document.getElementById('root')!).render(
  <React.StrictMode>
    <ApolloProvider client={client}>
      <App />
    </ApolloProvider>
  </React.StrictMode>
)

Step 5: First query with useQuery

Create a fragment for consistent selection and then query the first page.

// web/src/App.tsx
import { gql, useQuery } from '@apollo/client'
import { useState } from 'react'

const BOOK_FRAGMENT = gql`
  fragment BookFragment on Book {
    id
    title
    author
  }
`

const GET_BOOKS = gql`
  ${BOOK_FRAGMENT}
  query GetBooks($first: Int!, $after: ID) {
    books(first: $first, after: $after) {
      edges {
        cursor
        node { ...BookFragment }
      }
      pageInfo { endCursor hasNextPage }
    }
  }
`

export default function App() {
  const [pageSize] = useState(3)
  const { data, loading, error, fetchMore } = useQuery(GET_BOOKS, {
    variables: { first: pageSize }
  })

  if (loading) return <p>Loading</p>
  if (error) return <p>Error: {error.message}</p>

  const { edges, pageInfo } = data.books

  return (
    <main style={{ maxWidth: 720, margin: '2rem auto', fontFamily: 'system-ui, sans-serif' }}>
      <h1>Books</h1>
      <ul>
        {edges.map((e: any) => (
          <li key={e.node.id}>
            <strong>{e.node.title}</strong>  {e.node.author}
          </li>
        ))}
      </ul>

      {pageInfo.hasNextPage && (
        <button
          onClick={() => fetchMore({
            variables: { first: pageSize, after: pageInfo.endCursor }
          })}
        >Load more</button>
      )}

      <AddBook />
    </main>
  )
}

Step 6: Mutations with optimistic UI

Add a component to create a new book and instantly show it in the list.

// web/src/AddBook.tsx
import { gql, useMutation } from '@apollo/client'
import { useState } from 'react'

const ADD_BOOK = gql`
  mutation AddBook($title: String!, $author: String!) {
    addBook(title: $title, author: $author) { id title author }
  }
`

export function AddBook() {
  const [title, setTitle] = useState('')
  const [author, setAuthor] = useState('')
  const [addBook, { loading, error }] = useMutation(ADD_BOOK)

  const onSubmit = async (e: React.FormEvent) => {
    e.preventDefault()
    if (!title.trim() || !author.trim()) return

    await addBook({
      variables: { title, author },
      optimisticResponse: {
        addBook: { __typename: 'Book', id: 'temp-' + Date.now(), title, author }
      },
      update(cache, { data }) {
        // Prepend the new edge into the first page
        const newBook = data?.addBook
        if (!newBook) return

        cache.modify({
          fields: {
            books(existingConn) {
              if (!existingConn) return existingConn
              const newEdge = { __typename: 'BookEdge', cursor: newBook.id, node: newBook }
              return {
                ...existingConn,
                edges: [newEdge, ...existingConn.edges]
              }
            }
          }
        })
      }
    })

    setTitle(''); setAuthor('')
  }

  return (
    <form onSubmit={onSubmit} style={{ marginTop: '1.5rem', display: 'grid', gap: '.5rem' }}>
      <h2>Add a Book</h2>
      <input placeholder="Title" value={title} onChange={e => setTitle(e.target.value)} />
      <input placeholder="Author" value={author} onChange={e => setAuthor(e.target.value)} />
      <button disabled={loading}>Add</button>
      {error && <p style={{ color: 'crimson' }}>{error.message}</p>}
    </form>
  )
}

export default AddBook

Key ideas:

  • optimisticResponse makes the UI feel instant.
  • cache.modify updates the connection locally without requerying.

Step 7: Fragments and co‑location

We already used a BookFragment in App. Co‑locating fragments with components keeps selections consistent as the UI evolves. For larger apps, create a src/graphql directory for documents and fragments, and import them where needed.

Step 8: Handling loading, errors, and empty states

A polished UX needs clear states:

  • Loading: spinners/skeletons where content will appear
  • Error: visible message with retry
  • Empty: a friendly prompt to add data

Example wrapper:

function AsyncState({ loading, error, children }: any) {
  if (loading) return <div className="skeleton">Loading</div>
  if (error) return <div className="error">{String(error.message)}</div>
  return children
}

Step 9: Cursor pagination patterns

We used relayStylePagination to merge pages automatically. Here are tips:

  • Always request pageInfo { endCursor hasNextPage }.
  • Use fetchMore with the after variable.
  • Avoid offset pagination in GraphQL for large/real‑time lists; cursor pagination is more resilient to inserts/deletes.

If you need fine control, write a custom merge function in a type policy to concatenate edges based on cursors and to de‑duplicate nodes by id.

Step 10: Delete mutation and cache eviction

Deleting requires removing the node from the cache. Example:

const DELETE_BOOK = gql`
  mutation DeleteBook($id: ID!) { deleteBook(id: $id) }
`

function DeleteButton({ id }: { id: string }) {
  const [del] = useMutation(DELETE_BOOK, {
    variables: { id },
    optimisticResponse: { deleteBook: true },
    update(cache) {
      cache.modify({
        fields: {
          books(existingConn) {
            return {
              ...existingConn,
              edges: existingConn.edges.filter((e: any) => e.node.id !== id)
            }
          }
        }
      })
      cache.evict({ id: cache.identify({ __typename: 'Book', id }) })
      cache.gc()
    }
  })
  return <button onClick={() => del()}>Delete</button>
}

Step 11: Type‑safety with GraphQL Code Generator (optional)

Strong types reduce runtime bugs and autocomplete your queries.

Install and configure:

npm i -D @graphql-codegen/cli @graphql-codegen/typescript @graphql-codegen/typescript-operations @graphql-codegen/typescript-react-apollo
npx graphql-codegen init

Example codegen.yml:

schema: http://localhost:4000/graphql
documents: "web/src/**/*.{ts,tsx}"
generates:
  web/src/gql/:
    preset: client
  web/src/__generated__/types.ts:
    plugins:
      - typescript
      - typescript-operations
      - typescript-react-apollo
    config:
      withHooks: true

Run:

npx graphql-codegen

Now import typed hooks like useGetBooksQuery instead of useQuery.

Step 12: Component architecture and data flow

  • Keep data fetching close to the UI that needs it; lift state only when necessary.
  • Prefer small, focused queries over mega‑queries; fragments let you reuse fields cleanly.
  • Co‑locate mutations with the components that trigger them; co‑locate cache updates, too.

Suggested structure:

web/src/
  graphql/        # .graphql or .ts gql docs + fragments
  components/
    BooksList.tsx
    BookItem.tsx
    AddBook.tsx
  pages/
    HomePage.tsx
  apollo/
    client.ts      # ApolloClient setup

Step 13: Testing GraphQL React components

Use @testing-library/react with Apollo’s MockedProvider to render components with mocked GraphQL results.

// web/src/BooksList.test.tsx
import { render, screen, waitFor } from '@testing-library/react'
import { MockedProvider } from '@apollo/client/testing'
import App, { GET_BOOKS } from './App'

const mocks = [
  {
    request: { query: GET_BOOKS, variables: { first: 3 } },
    result: {
      data: {
        books: {
          edges: [
            { cursor: '1', node: { id: '1', title: 'Test', author: 'Alice', __typename: 'Book' }, __typename: 'BookEdge' }
          ],
          pageInfo: { endCursor: '1', hasNextPage: false, __typename: 'PageInfo' },
          __typename: 'BookConnection'
        }
      }
    }
  }
]

test('renders books', async () => {
  render(
    <MockedProvider mocks={mocks}>
      <App />
    </MockedProvider>
  )

  expect(screen.getByText(/Loading/)).toBeInTheDocument()
  await waitFor(() => screen.getByText('Test'))
})

Step 14: Security and production checklist

  • Schema design:
    • Use input types for complex mutations to validate shape.
    • Add nullability intentionally—don’t default everything to non‑null.
  • Server hardening:
    • Enable CORS with an allowlist in production.
    • Set request size limits; enable depth/complexity limits with query cost analysis.
    • Disable introspection in production if required; at least restrict to trusted environments.
  • Performance:
    • Use DataLoader (batching/caching) for N+1 queries against databases.
    • Add caching headers on HTTP responses where appropriate.
    • Enable persisted queries to cut payload sizes.
  • Observability:
    • Log operationName, duration, and errors.
    • Capture resolver timings and common error types.
  • Client hygiene:
    • Normalize with InMemoryCache and stable cache keys (id + __typename).
    • Avoid over‑fetching by leaning on fragments and component scoping.

Troubleshooting

  • CORS error from browser: ensure API runs on http://localhost:4000 and web uses the same origin in HttpLink. Configure cors({ origin: ‘http://localhost:5173’ }) for Vite dev.
  • Network error: check server log and that expressMiddleware is mounted at /graphql.
  • Cache not updating after mutation: verify update function or refetchQueries; ensure ids are unique and __typename is set in optimistic responses.
  • Pagination duplicates: confirm relayStylePagination is used and that edges have stable cursor values.

Where to go next

  • Add search by title and server filtering args.
  • Add subscriptions for real‑time updates (e.g., newBook).
  • Replace in‑memory storage with a database (PostgreSQL + Prisma) and add DataLoader.
  • Introduce authorization: add a currentUser query; send JWT in an Authorization header on the HttpLink.

Summary

You built a complete GraphQL + React stack:

  • A Node.js GraphQL API with Query and Mutation types, including cursor pagination
  • A React app with Apollo Client, fragments, and a normalized cache
  • Mutations with optimistic UI and manual cache updates
  • A path to production via testing, security, and performance best practices

This foundation is flexible, type‑safe, and ready to scale as your UI grows. Clone it, extend it, and ship faster with predictable data.

Related Posts