
Introduction
Don't use GraphQL unless your frontend needs to fetch data from 3+ related resources in one request. If you have one consumer hitting one endpoint, REST is simpler and faster. Still here? Good.
What follows is Apollo Server 5 in practice, the current major (Apollo Server 4 reached end-of-life on January 26, 2026). Schema design, resolvers, auth, error handling, plus an Apollo Client 4 front end. Coming from v4, the @apollo/server API is largely the same; v5 mainly requires Node.js 20 or later. But first, the decision that matters more than any of the implementation details.
GraphQL vs REST: When to Choose Which
GraphQL does not replace REST.
REST works. HTTP caching works out of the box. Every developer already understands it. Tooling is decades mature. If your API maps to database tables and clients all need roughly the same data, adding GraphQL is adding complexity for nothing.
Here's when GraphQL actually earns its cost. Your mobile app wants a user's name and avatar. The web dashboard wants that same user's name, email, posts, follower count, and notification settings. The partner API wants a different slice entirely. With REST, you build three endpoints or one bloated endpoint with sparse fieldsets. With GraphQL, each client writes the query it needs. One endpoint, one schema, three different result shapes.
Or you have six backend services and the frontend is stitching together data from four of them per page load. GraphQL as an aggregation layer — one request, resolvers fan out, client gets a single response. This is where it genuinely shines.
The costs nobody talks about until you're six months in:
Caching is hard. Every POST to the same endpoint with a different body. HTTP caching doesn't help. You need client-side normalized caching (Apollo Client does this) or server-side persisted queries. Both add complexity.
Performance monitoring is hard. One slow resolver in a deeply nested query tanks the whole response. With REST you see "/api/users took 3 seconds." With GraphQL you see "POST /graphql took 3 seconds" and now you're digging through resolver traces to find which field caused it.
Rate limiting is hard. One query might resolve two fields. Another might resolve two hundred. Treating them equally makes no sense, so you need query cost analysis. That's another library, another config, another thing to maintain.
And authorization is harder than REST because any field can be requested in any combination. You need field-level auth, not just endpoint-level auth.
So: more than two client types with meaningfully different data needs? GraphQL is worth it. Internal dashboard with one frontend? REST is fine. Do not adopt GraphQL because a conference talk made it sound exciting.
Setting Up Apollo Server
Apollo Server 5 runs standalone. No Express needed. Start without it — you can add Express later if you need middleware, but most people don't need it as early as they think.
mkdir graphql-bookstore && cd graphql-bookstore
npm init -y
npm pkg set type=module # ES modules, so top-level await works
npm install @apollo/server graphql
npm install -D typescript @types/node tsx
npx tsc --init --types nodenpm pkg set type=module matters: the server below uses top-level await, which only works in an ES module. TypeScript pays off in GraphQL work because your schema types map to TypeScript types and the compiler catches mismatches before anything runs. Note that tsx runs code without type-checking, so run npx tsc --noEmit (the default tsc --init config is strict) to catch type errors. The resolvers below type their arguments by hand; GraphQL Code Generator can generate those types from the schema.
import { ApolloServer } from '@apollo/server';
import { startStandaloneServer } from '@apollo/server/standalone';
// Type definitions describe the shape of your data
const typeDefs = `#graphql
type Book {
id: ID!
title: String!
author: Author!
genre: String!
publishedYear: Int
rating: Float
}
type Author {
id: ID!
name: String!
bio: String
books: [Book!]!
}
type Query {
books: [Book!]!
book(id: ID!): Book
authors: [Author!]!
author(id: ID!): Author
}
`;
// Sample data (in production, this would be a database)
const authors = [
{ id: '1', name: 'Chimamanda Adichie', bio: 'Nigerian novelist and essayist' },
{ id: '2', name: 'Haruki Murakami', bio: 'Japanese author of surrealist fiction' },
];
const books = [
{ id: '1', title: 'Half of a Yellow Sun', authorId: '1', genre: 'Historical Fiction', publishedYear: 2006, rating: 4.3 },
{ id: '2', title: 'Norwegian Wood', authorId: '2', genre: 'Literary Fiction', publishedYear: 1987, rating: 4.0 },
{ id: '3', title: 'Americanah', authorId: '1', genre: 'Contemporary Fiction', publishedYear: 2013, rating: 4.5 },
];
// Resolvers define how to fetch the data for each field
const resolvers = {
Query: {
books: () => books,
book: (_: unknown, { id }: { id: string }) => books.find(b => b.id === id),
authors: () => authors,
author: (_: unknown, { id }: { id: string }) => authors.find(a => a.id === id),
},
Book: {
author: (book: { authorId: string }) => authors.find(a => a.id === book.authorId),
},
Author: {
books: (author: { id: string }) => books.filter(b => b.authorId === author.id),
},
};
const server = new ApolloServer({ typeDefs, resolvers });
// Top-level await works because package.json has "type": "module"
const { url } = await startStandaloneServer(server, {
listen: { port: 4000 },
});
console.log(`Server ready at ${url}`);Run with npx tsx src/index.ts. Open http://localhost:4000 for Apollo Sandbox.
query {
books {
title
rating
author {
name
}
}
}
Title, rating, author name. Nothing else. No genre, no published year. The client asked for what it needed and got that. That's the whole idea.
Schema Design and Type Definitions
This is the hard part. Not the syntax — you'll learn that in twenty minutes. The hard part is designing a schema that won't make you miserable in six months when requirements change and three clients depend on your type definitions.
Getting it wrong early is genuinely painful. Changing a schema breaks clients. Adding nullable fields is fine. Removing fields or changing types is a migration.
Scalar Types
Five built-ins: String, Int, Float, Boolean, ID. Custom scalars exist for dates and emails but the built-ins cover most cases.
Object Types and Fields
! means non-nullable. String! always returns a string. [Book!]! returns a non-null array of non-null Books. Be careful with non-null — if a resolver throws for a non-null field, the error bubbles up and nulls the parent. Mark fields nullable unless you're certain they'll always have values.
Input Types
Mutations with many parameters need input types. Long argument lists get unreadable fast. As the schema grows, move it out of index.ts into its own module. A schema can only have one type Query, so the file below holds the full merged schema: the original types plus inputs, an enum, mutations and the extended Query. (If you split type definitions across files, use extend type Query in the later ones.)
// One merged schema: base types plus inputs, enum and mutations
export const typeDefs = `#graphql
type Book {
id: ID!
title: String!
author: Author!
genre: String!
publishedYear: Int
rating: Float
}
type Author {
id: ID!
name: String!
bio: String
books: [Book!]!
}
input CreateBookInput {
title: String!
authorId: ID!
genre: String!
publishedYear: Int
rating: Float
}
input UpdateBookInput {
title: String
genre: String
publishedYear: Int
rating: Float
}
type DeleteResponse {
success: Boolean!
message: String!
}
enum SortOrder {
ASC
DESC
}
type Query {
books(genre: String, sortBy: SortOrder): [Book!]!
book(id: ID!): Book
searchBooks(term: String!): [Book!]!
authors: [Author!]!
author(id: ID!): Author
}
type Mutation {
createBook(input: CreateBookInput!): Book!
updateBook(id: ID!, input: UpdateBookInput!): Book
deleteBook(id: ID!): DeleteResponse!
}
`;The SortOrder enum constrains arguments to a fixed set of values. Enums document valid options in the schema itself. Clients never have to guess which strings are acceptable.
Design the schema around client needs, not database tables. Mirroring Postgres tables one-to-one as GraphQL types is a common mistake. The frontend ends up making multiple queries to assemble what should be a single response. Structure types around what the UI needs to render. Let resolvers handle the mapping to whatever your storage looks like.
Writing Resolvers
Every field has a resolver. Apollo provides defaults for fields that map directly to parent properties, so you only write resolvers for fields needing custom logic. Four arguments: parent, args, context, info. You'll use the first three. info almost never.
The thing to understand before looking at the code: resolvers execute per-field, not per-query. Query a list of 50 books, each requesting its author, and the Book.author resolver fires 50 times. That's 50 database lookups for what should be one. This is the N+1 problem, and it's the single biggest performance trap in GraphQL. DataLoader batches those 50 lookups into one. You'll need it for anything beyond a demo.
The resolvers read from a small in-memory data module:
export interface Author {
id: string;
name: string;
bio?: string;
}
export interface Book {
id: string;
title: string;
authorId: string;
genre: string;
publishedYear?: number | null;
rating?: number | null;
}
// In-memory sample data (swap for a database later)
export const authors: Author[] = [
{ id: '1', name: 'Chimamanda Adichie', bio: 'Nigerian novelist and essayist' },
{ id: '2', name: 'Haruki Murakami', bio: 'Japanese author of surrealist fiction' },
];
export const books: Book[] = [
{ id: '1', title: 'Half of a Yellow Sun', authorId: '1', genre: 'Historical Fiction', publishedYear: 2006, rating: 4.3 },
{ id: '2', title: 'Norwegian Wood', authorId: '2', genre: 'Literary Fiction', publishedYear: 1987, rating: 4.0 },
{ id: '3', title: 'Americanah', authorId: '1', genre: 'Contemporary Fiction', publishedYear: 2013, rating: 4.5 },
];
let nextId = books.length + 1;
export const generateId = (): string => String(nextId++);import { GraphQLError } from 'graphql';
import { books, authors, generateId, type Book, type Author } from './data.js';
// Argument types mirror the schema (GraphQL Code Generator can produce these)
type SortOrder = 'ASC' | 'DESC';
interface CreateBookInput {
title: string;
authorId: string;
genre: string;
publishedYear?: number | null;
rating?: number | null;
}
interface UpdateBookInput {
title?: string | null;
genre?: string | null;
publishedYear?: number | null;
rating?: number | null;
}
export const resolvers = {
Query: {
books: (_: unknown, { genre, sortBy }: { genre?: string | null; sortBy?: SortOrder | null }) => {
let result = genre
? books.filter(b => b.genre === genre)
: [...books];
if (sortBy === 'ASC') {
result.sort((a, b) => a.title.localeCompare(b.title));
} else if (sortBy === 'DESC') {
result.sort((a, b) => b.title.localeCompare(a.title));
}
return result;
},
book: (_: unknown, { id }: { id: string }) => {
const book = books.find(b => b.id === id);
if (!book) {
throw new GraphQLError(`Book with id "${id}" not found`, {
extensions: { code: 'NOT_FOUND' },
});
}
return book;
},
searchBooks: (_: unknown, { term }: { term: string }) => {
const lowerTerm = term.toLowerCase();
return books.filter(b =>
b.title.toLowerCase().includes(lowerTerm) ||
b.genre.toLowerCase().includes(lowerTerm)
);
},
authors: () => authors,
author: (_: unknown, { id }: { id: string }) => authors.find(a => a.id === id),
},
Mutation: {
createBook: (_: unknown, { input }: { input: CreateBookInput }) => {
const author = authors.find(a => a.id === input.authorId);
if (!author) {
throw new GraphQLError('Author not found', {
extensions: { code: 'BAD_USER_INPUT' },
});
}
const newBook: Book = {
id: generateId(),
...input,
};
books.push(newBook);
return newBook;
},
updateBook: (_: unknown, { id, input }: { id: string; input: UpdateBookInput }) => {
const book = books.find(b => b.id === id);
if (!book) return null;
// Non-null schema fields ignore an explicit null
if (input.title != null) book.title = input.title;
if (input.genre != null) book.genre = input.genre;
if (input.publishedYear !== undefined) book.publishedYear = input.publishedYear;
if (input.rating !== undefined) book.rating = input.rating;
return book;
},
deleteBook: (_: unknown, { id }: { id: string }) => {
const index = books.findIndex(b => b.id === id);
if (index === -1) {
return { success: false, message: 'Book not found' };
}
books.splice(index, 1);
return { success: true, message: 'Book deleted successfully' };
},
},
// Nested resolvers for relationships
Book: {
author: (book: Book) => authors.find(a => a.id === book.authorId),
},
Author: {
books: (author: Author) => books.filter(b => b.authorId === author.id),
},
};
Understanding Nested Resolvers
Book.author and Author.books are where the magic and the pain both live. Client queries a book and its author. Apollo runs Query.book first. Gets an object with authorId but no author. Then calls Book.author with the book as parent.
If nobody requests the author field, that resolver never runs. No wasted lookups. But if everyone requests it for a list of books, you're back to the N+1 problem mentioned above.
Authentication and Context
Same pattern as any middleware. Extract the token, verify it, attach the user to a per-request object. In Apollo, that object is the context. This needs npm install jsonwebtoken and npm install -D @types/jsonwebtoken. The .js extensions in the imports are what Node's ES module resolution expects, even for .ts files.
import { ApolloServer } from '@apollo/server';
import { startStandaloneServer } from '@apollo/server/standalone';
import jwt from 'jsonwebtoken';
import { typeDefs } from './schema.js';
import { resolvers } from './resolvers.js';
export interface MyContext {
user: { id: string; email: string; role: string } | null;
}
const JWT_SECRET = process.env.JWT_SECRET;
if (!JWT_SECRET) {
throw new Error('JWT_SECRET is not set');
}
const server = new ApolloServer<MyContext>({
typeDefs,
resolvers,
});
const { url } = await startStandaloneServer(server, {
context: async ({ req }) => {
// Extract the token from the Authorization header
const token = req.headers.authorization?.replace('Bearer ', '');
if (!token) {
return { user: null };
}
try {
// The payload is whatever you signed at login
const decoded = jwt.verify(token, JWT_SECRET) as MyContext['user'];
return { user: decoded };
} catch (err) {
// Invalid token - return null user, don't throw
return { user: null };
}
},
listen: { port: 4000 },
});
console.log(`Server running at ${url}`);
No error on missing tokens. user is null. Public queries work. Mutations check for auth and reject if needed.
import { GraphQLError } from 'graphql';
import type { MyContext } from './server.js';
// Helper to enforce authentication
export function requireAuth(context: MyContext) {
if (!context.user) {
throw new GraphQLError('You must be logged in to perform this action', {
extensions: {
code: 'UNAUTHENTICATED',
http: { status: 401 },
},
});
}
return context.user;
}
// Helper to enforce specific roles
export function requireRole(context: MyContext, role: string) {
const user = requireAuth(context);
if (user.role !== role) {
throw new GraphQLError(`This action requires the "${role}" role`, {
extensions: { code: 'FORBIDDEN' },
});
}
return user;
}
// Using these in mutations (context is the third resolver argument)
export const protectedResolvers = {
Mutation: {
createBook: (_: unknown, args: unknown, context: MyContext) => {
requireAuth(context);
// ... create book logic
},
deleteBook: (_: unknown, args: unknown, context: MyContext) => {
requireRole(context, 'ADMIN');
// ... delete book logic
},
},
};Helper functions over directives for most projects. @auth(role: ADMIN) in the schema looks elegant but is harder to debug and test.
Don't block unauthenticated requests in middleware before they reach GraphQL. Some queries are public. Some mutations need auth. Resolver-level checks give you that granularity. Middleware-level auth for GraphQL sounds clean. It isn't.
Error Handling and Validation
Once a request reaches execution, Apollo Server returns 200 even when resolvers fail. Errors live in an errors array alongside data. Requests that cannot be parsed or fail validation against the schema get a 400 instead, and you can set a status yourself with the http extension, as requireAuth does above.
This confuses people coming from REST. Partial success is real — query three books, one resolver fails, you get two books in data and one entry in errors. Your monitoring tools that alert on HTTP 500s? Useless here. You need to parse the response body. Or use Apollo Studio. Either way, it's different.
import { ApolloServer } from '@apollo/server';
import { GraphQLError } from 'graphql';
import { typeDefs } from './schema.js';
import { resolvers } from './resolvers.js';
interface BookInputFields {
title?: string | null;
publishedYear?: number | null;
rating?: number | null;
}
// Validation: check input before processing
export function validateBookInput(input: BookInputFields) {
const errors: string[] = [];
if (input.title != null && input.title.trim().length < 1) {
errors.push('Title cannot be empty');
}
if (input.publishedYear != null && input.publishedYear > new Date().getFullYear()) {
errors.push('Published year cannot be in the future');
}
if (input.rating != null && (input.rating < 0 || input.rating > 5)) {
errors.push('Rating must be between 0 and 5');
}
if (errors.length > 0) {
throw new GraphQLError('Validation failed', {
extensions: {
code: 'BAD_USER_INPUT',
validationErrors: errors,
},
});
}
}
// Format errors globally before sending to the client
export const server = new ApolloServer({
typeDefs,
resolvers,
formatError: (formattedError, error) => {
// Log internal errors for debugging
if (formattedError.extensions?.code === 'INTERNAL_SERVER_ERROR') {
console.error('Internal error:', error);
// Don't expose internal details to the client
return {
message: 'An unexpected error occurred',
extensions: { code: 'INTERNAL_SERVER_ERROR' },
};
}
// Pass through expected errors (validation, auth, etc.)
return formattedError;
},
});
formatError strips internal details before they leave the server. Clients need error codes. They don't need stack traces.
Collect validation errors into an array and throw them together. Bailing on the first one means clients discover errors one at a time across multiple round trips. Annoying.
Return null vs throw? Book by ID doesn't exist: throw. Search returns no results: empty array. An empty search is valid data. A missing specific resource is an error.
Client-Side Integration with Apollo Client
Apollo Client does more than fetch data. Normalized in-memory cache, optimistic updates, automatic re-renders when data changes. It's opinionated and heavy. If you want something lighter, urql is worth considering. But Apollo Client has the biggest community and the most Stack Overflow answers, which matters when you're debugging at midnight.
The example uses Apollo Client 4 (npm install @apollo/client graphql rxjs). Two changes from v3 trip people up: the uri and headers shortcuts on ApolloClient are gone, so you build an HttpLink yourself, and the React hooks and ApolloProvider now import from @apollo/client/react.
import { ApolloClient, ApolloLink, HttpLink, InMemoryCache, gql } from '@apollo/client';
import { SetContextLink } from '@apollo/client/link/context';
import { ApolloProvider, useQuery, useMutation } from '@apollo/client/react';
// Apollo Client 4: the HTTP transport is an explicit link
const httpLink = new HttpLink({ uri: 'http://localhost:4000' });
// Runs before every request, so a token saved after login is picked up
const authLink = new SetContextLink((prevContext) => {
const token = localStorage.getItem('token');
return {
headers: {
...prevContext.headers,
...(token ? { authorization: `Bearer ${token}` } : {}),
},
};
});
// Initialize the client
const client = new ApolloClient({
link: ApolloLink.from([authLink, httpLink]),
cache: new InMemoryCache(),
});
interface GetBooksData {
books: { id: string; title: string; rating: number | null; author: { name: string } }[];
}
// Define queries as tagged template literals
const GET_BOOKS = gql`
query GetBooks($genre: String) {
books(genre: $genre) {
id
title
rating
author {
name
}
}
}
`;
const CREATE_BOOK = gql`
mutation CreateBook($input: CreateBookInput!) {
createBook(input: $input) {
id
title
genre
author {
name
}
}
}
`;
// Component that fetches and displays books
function BookList({ genre }: { genre: string }) {
const { loading, error, data } = useQuery<GetBooksData, { genre: string }>(GET_BOOKS, {
variables: { genre },
});
const [createBook] = useMutation(CREATE_BOOK, {
// Refetch the book list after creating a new book
refetchQueries: [{ query: GET_BOOKS, variables: { genre } }],
});
if (loading) return <p>Loading books...</p>;
if (error) return <p>Error: {error.message}</p>;
if (!data) return null;
return (
<div>
{data.books.map(book => (
<div key={book.id}>
<h3>{book.title}</h3>
<p>By {book.author.name}</p>
<span>Rating: {book.rating}/5</span>
</div>
))}
<button
onClick={() => createBook({
variables: { input: { title: 'Kafka on the Shore', authorId: '2', genre } },
})}
>
Add sample book
</button>
</div>
);
}
// Wrap your app with ApolloProvider
export function App() {
return (
<ApolloProvider client={client}>
<BookList genre="Literary Fiction" />
</ApolloProvider>
);
}The auth link runs before every request, so it reads the token at request time. Passing a fixed authorization header when the client is created would bake in whatever token existed at page load and keep sending it after the user logs in or out. useQuery handles loading, error, and data. useMutation returns a callable. refetchQueries re-runs queries after mutations. Finer options exist but this works to start.
The InMemoryCache is normalized by type and ID. Fetch a list of books, then query one by ID — it might not hit the network at all. This is the killer feature over plain fetch calls. And the source of the most confusing bugs when the cache holds stale data and you can't figure out why your UI isn't updating.
Put queries in separate files. queries/books.ts, not inline. And use GraphQL Code Generator for end-to-end type safety from schema to component. It produces TypeScript types from your schema automatically. Worth the setup time.
Conclusion
Before production: add DataLoader and query limits. DataLoader solves N+1. For limits, use a maintained package rather than graphql-depth-limit, which has not been published since 2017. GraphQL Armor (@escape.tech/graphql-armor) supports Apollo Server 4 and 5 and bundles depth, cost, alias and token limits, so clients cannot craft queries that flatten your server.
If you can do a third thing, make it cursor-based pagination. Switching from offset to cursor later is a migration that touches server and client code. Get it right early.
The GraphQL ecosystem is fragmented — Apollo, Yoga, Pothos, Relay, urql. Picking a stack is harder than learning GraphQL itself. That's the real cost nobody warns you about.