Blog/Tutorials & Step-by-Step/Build a GraphQL API with Apollo Server and Next.js
POST
October 18, 2025
LAST UPDATEDOctober 18, 2025

Build a GraphQL API with Apollo Server and Next.js

Step-by-step guide to building a type-safe GraphQL API with Apollo Server in Next.js API routes, plus Apollo Client on the frontend.

Tags

GraphQLApolloNext.jsAPI
Build a GraphQL API with Apollo Server and Next.js
4 min read

Build a GraphQL API with Apollo Server and Next.js

In this tutorial, you will build a full-stack GraphQL application using Apollo Server embedded in Next.js API routes and Apollo Client on the frontend. You will define a schema, write resolvers, set up queries and mutations, implement caching strategies, and handle errors properly. By the end, you will have a working bookshelf app where users can browse and manage their book collection.

TL;DR

Mount Apollo Server in a Next.js API route using @as-integrations/next, define your schema with type definitions and resolvers, then consume it on the frontend with Apollo Client's useQuery and useMutation hooks. The InMemoryCache handles most caching automatically.

Prerequisites

  • Next.js 14+ with the App Router
  • Node.js 18+
  • Basic understanding of TypeScript and React
  • Familiarity with REST APIs (GraphQL knowledge is not required)
bash
npx create-next-app@latest graphql-bookshelf --typescript --app --tailwind
cd graphql-bookshelf
npm install @apollo/server @as-integrations/next @apollo/client graphql

Step 1: Define the GraphQL Schema

The schema is the contract between your frontend and backend. It defines what data exists and what operations are available.

typescript
// src/graphql/schema.ts
export const typeDefs = `#graphql
  type Book {
    id: ID!
    title: String!
    author: String!
    isbn: String
    publishedYear: Int
    genre: String!
    rating: Float
    description: String
    createdAt: String!
  }
 
  type Query {
    books(genre: String, limit: Int, offset: Int): [Book!]!
    book(id: ID!): Book
    searchBooks(query: String!): [Book!]!
  }
 
  type Mutation {
    addBook(input: AddBookInput!): Book!
    updateBook(id: ID!, input: UpdateBookInput!): Book!
    deleteBook(id: ID!): DeleteResponse!
  }
 
  input AddBookInput {
    title: String!
    author: String!
    isbn: String
    publishedYear: Int
    genre: String!
    rating: Float
    description: String
  }
 
  input UpdateBookInput {
    title: String
    author: String
    isbn: String
    publishedYear: Int
    genre: String
    rating: Float
    description: String
  }
 
  type DeleteResponse {
    success: Boolean!
    message: String!
  }
`;

Input types keep mutations clean by grouping related arguments. The ! suffix means a field is non-nullable. Notice that UpdateBookInput fields are all optional since you might only want to update the rating without sending every other field.

Step 2: Create the Data Layer

For this tutorial we use an in-memory store. In production, you would replace this with database calls.

typescript
// src/graphql/data.ts
export interface BookRecord {
  id: string;
  title: string;
  author: string;
  isbn: string | null;
  publishedYear: number | null;
  genre: string;
  rating: number | null;
  description: string | null;
  createdAt: string;
}
 
let books: BookRecord[] = [
  {
    id: "1",
    title: "The Pragmatic Programmer",
    author: "David Thomas, Andrew Hunt",
    isbn: "978-0135957059",
    publishedYear: 2019,
    genre: "Programming",
    rating: 4.7,
    description: "A guide to becoming a better programmer through pragmatic thinking.",
    createdAt: new Date("2024-01-15").toISOString(),
  },
  {
    id: "2",
    title: "Designing Data-Intensive Applications",
    author: "Martin Kleppmann",
    isbn: "978-1449373320",
    publishedYear: 2017,
    genre: "Architecture",
    rating: 4.8,
    description: "The big ideas behind reliable, scalable, and maintainable systems.",
    createdAt: new Date("2024-02-20").toISOString(),
  },
  {
    id: "3",
    title: "Clean Code",
    author: "Robert C. Martin",
    isbn: "978-0132350884",
    publishedYear: 2008,
    genre: "Programming",
    rating: 4.4,
    description: "A handbook of agile software craftsmanship.",
    createdAt: new Date("2024-03-10").toISOString(),
  },
];
 
let nextId = 4;
 
export const db = {
  getBooks(genre?: string, limit = 10, offset = 0): BookRecord[] {
    let filtered = genre
      ? books.filter((b) => b.genre.toLowerCase() === genre.toLowerCase())
      : books;
    return filtered.slice(offset, offset + limit);
  },
 
  getBookById(id: string): BookRecord | undefined {
    return books.find((b) => b.id === id);
  },
 
  searchBooks(query: string): BookRecord[] {
    const lower = query.toLowerCase();
    return books.filter(
      (b) =>
        b.title.toLowerCase().includes(lower) ||
        b.author.toLowerCase().includes(lower)
    );
  },
 
  addBook(input: Omit<BookRecord, "id" | "createdAt">): BookRecord {
    const book: BookRecord = {
      ...input,
      id: String(nextId++),
      createdAt: new Date().toISOString(),
    };
    books.push(book);
    return book;
  },
 
  updateBook(id: string, input: Partial<BookRecord>): BookRecord | null {
    const index = books.findIndex((b) => b.id === id);
    if (index === -1) return null;
    books[index] = { ...books[index], ...input };
    return books[index];
  },
 
  deleteBook(id: string): boolean {
    const index = books.findIndex((b) => b.id === id);
    if (index === -1) return false;
    books.splice(index, 1);
    return true;
  },
};

Step 3: Write the Resolvers

Resolvers are functions that populate each field in your schema. They receive the parent object, arguments, and a shared context.

typescript
// src/graphql/resolvers.ts
import { db } from "./data";
import { GraphQLError } from "graphql";
 
export const resolvers = {
  Query: {
    books: (
      _parent: unknown,
      args: { genre?: string; limit?: number; offset?: number }
    ) => {
      return db.getBooks(args.genre, args.limit ?? 10, args.offset ?? 0);
    },
 
    book: (_parent: unknown, args: { id: string }) => {
      const book = db.getBookById(args.id);
      if (!book) {
        throw new GraphQLError("Book not found", {
          extensions: { code: "NOT_FOUND" },
        });
      }
      return book;
    },
 
    searchBooks: (_parent: unknown, args: { query: string }) => {
      if (args.query.length < 2) {
        throw new GraphQLError("Search query must be at least 2 characters", {
          extensions: { code: "BAD_USER_INPUT" },
        });
      }
      return db.searchBooks(args.query);
    },
  },
 
  Mutation: {
    addBook: (
      _parent: unknown,
      args: { input: Parameters<typeof db.addBook>[0] }
    ) => {
      return db.addBook(args.input);
    },
 
    updateBook: (
      _parent: unknown,
      args: { id: string; input: Record<string, unknown> }
    ) => {
      const updated = db.updateBook(args.id, args.input);
      if (!updated) {
        throw new GraphQLError("Book not found", {
          extensions: { code: "NOT_FOUND" },
        });
      }
      return updated;
    },
 
    deleteBook: (_parent: unknown, args: { id: string }) => {
      const success = db.deleteBook(args.id);
      return {
        success,
        message: success
          ? "Book deleted successfully"
          : "Book not found",
      };
    },
  },
};

Using GraphQLError with extension codes lets the frontend handle specific error types differently, such as showing a "not found" page versus a validation error message.

Step 4: Mount Apollo Server in Next.js

Create an API route that handles GraphQL requests.

typescript
// src/app/api/graphql/route.ts
import { ApolloServer } from "@apollo/server";
import { startServerAndCreateNextHandler } from "@as-integrations/next";
import { NextRequest } from "next/server";
import { typeDefs } from "@/graphql/schema";
import { resolvers } from "@/graphql/resolvers";
 
const server = new ApolloServer({
  typeDefs,
  resolvers,
  formatError: (formattedError, error) => {
    // In production, hide internal error details
    if (process.env.NODE_ENV === "production") {
      if (formattedError.extensions?.code === "INTERNAL_SERVER_ERROR") {
        return {
          message: "An unexpected error occurred",
          extensions: { code: "INTERNAL_SERVER_ERROR" },
        };
      }
    }
 
    return formattedError;
  },
});
 
const handler = startServerAndCreateNextHandler<NextRequest>(server, {
  context: async (req) => {
    // Extract auth token from headers if needed
    const token = req.headers.get("authorization")?.replace("Bearer ", "");
    return {
      token,
      // Add user lookup, database connections, etc.
    };
  },
});
 
export { handler as GET, handler as POST };

The context function runs on every request, making it the right place to extract authentication tokens, initialize database connections, or set up DataLoaders for batching.

Step 5: Configure Apollo Client

Set up Apollo Client to communicate with your GraphQL endpoint from React components.

typescript
// src/lib/apolloClient.ts
import { ApolloClient, InMemoryCache, HttpLink } from "@apollo/client";
 
function createApolloClient() {
  return new ApolloClient({
    link: new HttpLink({
      uri: "/api/graphql",
      headers: {
        "Content-Type": "application/json",
      },
    }),
    cache: new InMemoryCache({
      typePolicies: {
        Query: {
          fields: {
            books: {
              // Merge paginated results
              keyArgs: ["genre"],
              merge(existing = [], incoming) {
                return [...existing, ...incoming];
              },
            },
          },
        },
      },
    }),
    defaultOptions: {
      watchQuery: {
        fetchPolicy: "cache-and-network",
      },
    },
  });
}
 
export const client = createApolloClient();

The typePolicies configuration tells Apollo how to merge paginated results. The keyArgs: ["genre"] setting means separate caches are maintained for each genre filter.

Step 6: Create the Apollo Provider

Wrap your app with the ApolloProvider to make the client available to all components.

typescript
// src/components/ApolloWrapper.tsx
"use client";
 
import { ApolloProvider } from "@apollo/client";
import { client } from "@/lib/apolloClient";
 
export function ApolloWrapper({ children }: { children: React.ReactNode }) {
  return <ApolloProvider client={client}>{children}</ApolloProvider>;
}
typescript
// src/app/layout.tsx
import { ApolloWrapper } from "@/components/ApolloWrapper";
 
export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        <ApolloWrapper>{children}</ApolloWrapper>
      </body>
    </html>
  );
}

Step 7: Build Frontend Components with Queries and Mutations

Now use Apollo Client hooks to fetch and mutate data.

typescript
// src/graphql/operations.ts
import { gql } from "@apollo/client";
 
export const GET_BOOKS = gql`
  query GetBooks($genre: String, $limit: Int, $offset: Int) {
    books(genre: $genre, limit: $limit, offset: $offset) {
      id
      title
      author
      genre
      rating
      publishedYear
    }
  }
`;
 
export const GET_BOOK = gql`
  query GetBook($id: ID!) {
    book(id: $id) {
      id
      title
      author
      isbn
      publishedYear
      genre
      rating
      description
      createdAt
    }
  }
`;
 
export const ADD_BOOK = gql`
  mutation AddBook($input: AddBookInput!) {
    addBook(input: $input) {
      id
      title
      author
      genre
      rating
    }
  }
`;
 
export const DELETE_BOOK = gql`
  mutation DeleteBook($id: ID!) {
    deleteBook(id: $id) {
      success
      message
    }
  }
`;
typescript
// src/components/BookList.tsx
"use client";
 
import { useQuery, useMutation } from "@apollo/client";
import { GET_BOOKS, DELETE_BOOK } from "@/graphql/operations";
import { useState } from "react";
 
interface Book {
  id: string;
  title: string;
  author: string;
  genre: string;
  rating: number | null;
  publishedYear: number | null;
}
 
export function BookList() {
  const [genre, setGenre] = useState<string | undefined>();
 
  const { data, loading, error } = useQuery<{ books: Book[] }>(GET_BOOKS, {
    variables: { genre, limit: 10, offset: 0 },
  });
 
  const [deleteBook] = useMutation(DELETE_BOOK, {
    update(cache, { data: { deleteBook: result } }, { variables }) {
      if (result.success && variables) {
        // Remove the deleted book from the cache
        cache.modify({
          fields: {
            books(existingBooks = [], { readField }) {
              return existingBooks.filter(
                (bookRef: any) => readField("id", bookRef) !== variables.id
              );
            },
          },
        });
      }
    },
  });
 
  if (loading) return <p>Loading books...</p>;
  if (error) return <p>Error: {error.message}</p>;
 
  return (
    <div>
      <div className="flex gap-2 mb-4">
        <button onClick={() => setGenre(undefined)}>All</button>
        <button onClick={() => setGenre("Programming")}>Programming</button>
        <button onClick={() => setGenre("Architecture")}>Architecture</button>
      </div>
 
      <ul className="space-y-4">
        {data?.books.map((book) => (
          <li key={book.id} className="p-4 border rounded">
            <h3 className="text-lg font-bold">{book.title}</h3>
            <p className="text-gray-600">by {book.author}</p>
            <p className="text-sm">
              {book.genre} | {book.publishedYear} | Rating: {book.rating}/5
            </p>
            <button
              onClick={() => deleteBook({ variables: { id: book.id } })}
              className="mt-2 text-red-500 text-sm"
            >
              Delete
            </button>
          </li>
        ))}
      </ul>
    </div>
  );
}

The update function in useMutation directly modifies the Apollo cache after a successful deletion, so the UI updates instantly without refetching the entire book list.

Step 8: Add a Book Form with Mutation

typescript
// src/components/AddBookForm.tsx
"use client";
 
import { useMutation } from "@apollo/client";
import { ADD_BOOK, GET_BOOKS } from "@/graphql/operations";
import { useState } from "react";
 
export function AddBookForm() {
  const [form, setForm] = useState({
    title: "",
    author: "",
    genre: "Programming",
    publishedYear: new Date().getFullYear(),
    rating: 0,
    description: "",
  });
 
  const [addBook, { loading, error }] = useMutation(ADD_BOOK, {
    refetchQueries: [{ query: GET_BOOKS }],
    onCompleted: () => {
      setForm({
        title: "",
        author: "",
        genre: "Programming",
        publishedYear: new Date().getFullYear(),
        rating: 0,
        description: "",
      });
    },
  });
 
  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    addBook({
      variables: {
        input: {
          ...form,
          publishedYear: Number(form.publishedYear),
          rating: Number(form.rating),
        },
      },
    });
  };
 
  return (
    <form onSubmit={handleSubmit} className="space-y-4 p-4 border rounded">
      <h2 className="text-xl font-bold">Add a Book</h2>
 
      <input
        type="text"
        placeholder="Title"
        value={form.title}
        onChange={(e) => setForm((f) => ({ ...f, title: e.target.value }))}
        required
        className="w-full p-2 border rounded"
      />
 
      <input
        type="text"
        placeholder="Author"
        value={form.author}
        onChange={(e) => setForm((f) => ({ ...f, author: e.target.value }))}
        required
        className="w-full p-2 border rounded"
      />
 
      <select
        value={form.genre}
        onChange={(e) => setForm((f) => ({ ...f, genre: e.target.value }))}
        className="w-full p-2 border rounded"
      >
        <option value="Programming">Programming</option>
        <option value="Architecture">Architecture</option>
        <option value="DevOps">DevOps</option>
        <option value="Design">Design</option>
      </select>
 
      <input
        type="number"
        placeholder="Rating (0-5)"
        value={form.rating}
        onChange={(e) => setForm((f) => ({ ...f, rating: parseFloat(e.target.value) }))}
        min={0}
        max={5}
        step={0.1}
        className="w-full p-2 border rounded"
      />
 
      <button
        type="submit"
        disabled={loading}
        className="px-4 py-2 bg-blue-500 text-white rounded disabled:opacity-50"
      >
        {loading ? "Adding..." : "Add Book"}
      </button>
 
      {error && <p className="text-red-500">{error.message}</p>}
    </form>
  );
}

Using refetchQueries is the simplest way to keep the book list in sync after adding a new book. For better performance in production, you can use the update function to write the new book directly to the cache instead.

Step 9: Error Handling Best Practices

Implement a global error handler and per-component error boundaries.

typescript
// src/lib/apolloClient.ts (enhanced)
import { ApolloClient, InMemoryCache, HttpLink, from } from "@apollo/client";
import { onError } from "@apollo/client/link/error";
 
const errorLink = onError(({ graphQLErrors, networkError }) => {
  if (graphQLErrors) {
    graphQLErrors.forEach(({ message, extensions }) => {
      console.error(`[GraphQL Error] ${extensions?.code}: ${message}`);
 
      if (extensions?.code === "UNAUTHENTICATED") {
        // Redirect to login
        window.location.href = "/login";
      }
    });
  }
 
  if (networkError) {
    console.error(`[Network Error]: ${networkError.message}`);
  }
});
 
const httpLink = new HttpLink({ uri: "/api/graphql" });
 
export const client = new ApolloClient({
  link: from([errorLink, httpLink]),
  cache: new InMemoryCache(),
});

The onError link intercepts every error before it reaches components. This is the right place to handle authentication failures globally instead of checking in every component.

The Complete Stack

Here is what you have built:

  1. GraphQL Schema with types, queries, mutations, and input types
  2. Resolvers that implement business logic with proper error handling
  3. Apollo Server mounted in a Next.js API route with auth context
  4. Apollo Client with InMemoryCache and type policies for pagination
  5. React components using useQuery and useMutation hooks
  6. Error handling at both the global and component level

Next Steps

  • Add DataLoader to solve the N+1 query problem for related data
  • Generate TypeScript types from your schema using GraphQL Code Generator
  • Implement subscriptions with WebSockets for real-time updates
  • Add persisted queries to reduce request sizes in production
  • Set up Apollo Studio for schema monitoring and query analytics

FAQ

Can you use Apollo Server inside Next.js API routes?

Yes, using @as-integrations/next you can mount Apollo Server as a Next.js API route handler that works with both the Pages Router and the App Router.

How does Apollo Client caching work?

Apollo Client uses an InMemoryCache that normalizes data by __typename and id, so updating one entity automatically updates every query that references it without refetching.

Should I use GraphQL or REST for my Next.js app?

Use GraphQL when your frontend needs flexible queries across related data and you want to avoid over-fetching. Use REST when your API is simple, CRUD-based, or consumed by many different clients.

How do you handle errors in GraphQL?

GraphQL returns errors in the response body alongside partial data. Use Apollo Server formatError to sanitize error messages in production and Apollo Client errorPolicy to control how errors affect the UI.

What is the N+1 problem in GraphQL and how do you solve it?

The N+1 problem occurs when a resolver executes one query per item in a list. Solve it with DataLoader, which batches and deduplicates database calls within a single request.

Collaboration

Need help with a project?

Let's Build It

I help startups and established companies design, build, and scale world-class digital products. From deep technical architecture to pixel-perfect UI — let's bring your vision to life.

SH

Article Author

Sadam Hussain

Senior Full Stack Developer

Senior Full Stack Developer with over 7 years of experience building React, Next.js, Node.js, TypeScript, and AI-powered web platforms.

Related Articles

How to Add Observability to a Node.js App with OpenTelemetry
Mar 21, 20265 min read
Node.js
OpenTelemetry
Observability

How to Add Observability to a Node.js App with OpenTelemetry

Learn how to instrument a Node.js app with OpenTelemetry for traces, metrics, and logs, and build a practical observability setup for production debugging.

How to Build a Backend-for-Frontend (BFF) with Next.js and Node.js
Mar 21, 20266 min read
Next.js
Node.js
BFF

How to Build a Backend-for-Frontend (BFF) with Next.js and Node.js

A practical guide to building a Backend-for-Frontend with Next.js and Node.js for API aggregation, auth handling, caching, and frontend-specific data shaping.

How I Structure CI/CD for Next.js, Docker, and GitHub Actions
Mar 21, 20265 min read
CI/CD
Next.js
Docker

How I Structure CI/CD for Next.js, Docker, and GitHub Actions

A practical CI/CD blueprint for Next.js apps using Docker and GitHub Actions, including testing, image builds, deployment stages, cache strategy, and release safety.