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
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)
npx create-next-app@latest graphql-bookshelf --typescript --app --tailwind
cd graphql-bookshelf
npm install @apollo/server @as-integrations/next @apollo/client graphqlStep 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.
// 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.
// 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.
// 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.
// 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.
// 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.
// 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>;
}// 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.
// 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
}
}
`;// 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
// 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.
// 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:
- ›GraphQL Schema with types, queries, mutations, and input types
- ›Resolvers that implement business logic with proper error handling
- ›Apollo Server mounted in a Next.js API route with auth context
- ›Apollo Client with InMemoryCache and type policies for pagination
- ›React components using useQuery and useMutation hooks
- ›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.
Related Articles
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
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
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.