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 4 in practice. Schema design, resolvers, auth, error handling. The setup is cleaner than v3, error handling is more intuitive, and TypeScript support is solid. 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. Anyone who tells you otherwise is selling a GraphQL product.

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 4 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.

terminal
mkdir graphql-bookstore && cd graphql-bookstore
npm init -y
npm install @apollo/server graphql
npm install -D typescript @types/node tsx
npx tsc --init

TypeScript is non-negotiable for GraphQL work. Your schema types map to TypeScript types, so the compiler catches mismatches before anything runs. Plain JavaScript GraphQL servers are a debugging nightmare.

src/index.ts
import { ApolloServer } from'@apollo/server';
import { startStandaloneServer } from'@apollo/server/standalone';
// Type definitions describe the shape of your dataconst 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 fieldconst resolvers = {
 Query: {
 books: () => books,
 book: (_, { id }) => books.find(b => b.id === id),
 authors: () => authors,
 author: (_, { id }) => authors.find(a => a.id === id),
 },
 Book: {
 author: (book) => authors.find(a => a.id === book.authorId),
 },
 Author: {
 books: (author) => books.filter(b => b.authorId === author.id),
 },
};
const server = newApolloServer({ typeDefs, resolvers });
const { url } = awaitstartStandaloneServer(server, {
 listen: { port: 4000 },
});
console.log(`Server ready at ${url}`);

Run with npx tsx src/index.ts. Open http://localhost:4000 for Apollo Sandbox.

GraphQL Query
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.

schema-extensions.graphql
inputCreateBookInput {
 title: String!
 authorId: ID!
 genre: String!
 publishedYear: Int
 rating: Float
}
inputUpdateBookInput {
 title: String
 genre: String
 publishedYear: Int
 rating: Float
}
typeDeleteResponse {
 success: Boolean!
 message: String!
}
typeMutation {
 createBook(input: CreateBookInput!): Book!
 updateBook(id: ID!, input: UpdateBookInput!): Book
 deleteBook(id: ID!): DeleteResponse!
}
enumSortOrder {
 ASC
 DESC
}
typeQuery {
 books(genre: String, sortBy: SortOrder): [Book!]!
 book(id: ID!): Book
 searchBooks(term: String!): [Book!]!
}

Standard stuff.

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 the most common mistake I see. 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.

src/resolvers.ts
import { GraphQLError } from'graphql';
import { books, authors, generateId } from'./data';
export const resolvers = {
 Query: {
 books: (_, { genre, sortBy }) => {
 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: (_, { id }) => {
 const book = books.find(b => b.id === id);
 if (!book) {
 throw newGraphQLError(`Book with id "${id}" not found`, {
 extensions: { code: 'NOT_FOUND' },
 });
 }
 return book;
 },
 searchBooks: (_, { term }) => {
 const lowerTerm = term.toLowerCase();
 return books.filter(b =>
 b.title.toLowerCase().includes(lowerTerm) ||
 b.genre.toLowerCase().includes(lowerTerm)
 );
 },
 authors: () => authors,
 author: (_, { id }) => authors.find(a => a.id === id),
 },
 Mutation: {
 createBook: (_, { input }) => {
 const author = authors.find(a => a.id === input.authorId);
 if (!author) {
 throw newGraphQLError('Author not found', {
 extensions: { code: 'BAD_USER_INPUT' },
 });
 }
 const newBook = {
 id: generateId(),
 ...input,
 };
 books.push(newBook);
 return newBook;
 },
 updateBook: (_, { id, input }) => {
 const index = books.findIndex(b => b.id === id);
 if (index === -1) return null;
 books[index] = { ...books[index], ...input };
 return books[index];
 },
 deleteBook: (_, { id }) => {
 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 relationshipsBook: {
 author: (book) => authors.find(a => a.id === book.authorId),
 },
 Author: {
 books: (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.

src/server.ts
import { ApolloServer } from'@apollo/server';
import { startStandaloneServer } from'@apollo/server/standalone';
import jwt from'jsonwebtoken';
import { typeDefs } from'./schema';
import { resolvers } from'./resolvers';
interfaceMyContext {
 user: { id: string; email: string; role: string } | null;
}
const server = new ApolloServer<MyContext>({
 typeDefs,
 resolvers,
});
const { url } = awaitstartStandaloneServer(server, {
 context: async ({ req }) => {
 // Extract the token from the Authorization headerconst token = req.headers.authorization?.replace('Bearer ', '');
 if (!token) {
 return { user: null };
 }
 try {
 const decoded = jwt.verify(token, process.env.JWT_SECRET);
 return { user: decoded };
 } catch (err) {
 // Invalid token - return null user, don't throwreturn { 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.

Auth check in resolver
// Helper to enforce authenticationfunctionrequireAuth(context) {
 if (!context.user) {
 throw newGraphQLError('You must be logged in to perform this action', {
 extensions: {
 code: 'UNAUTHENTICATED',
 http: { status: 401 },
 },
 });
 }
 return context.user;
}
// Helper to enforce specific rolesfunctionrequireRole(context, role) {
 const user = requireAuth(context);
 if (user.role !== role) {
 throw newGraphQLError(`This action requires the "${role}" role`, {
 extensions: { code: 'FORBIDDEN' },
 });
 }
 return user;
}
// Using these in mutationsconst resolvers = {
 Mutation: {
 createBook: (_, { input }, context) => {
 requireAuth(context);
 // ... create book logic
 },
 deleteBook: (_, { id }, context) => {
 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

Everything returns 200. Errors live in an errors array alongside data.

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.

src/errors.ts
import { GraphQLError } from'graphql';
// Validation: check input before processingfunctionvalidateBookInput(input) {
 const errors = [];
 if (input.title && input.title.trim().length< 1) {
 errors.push('Title cannot be empty');
 }
 if (input.publishedYear && input.publishedYear >newDate().getFullYear()) {
 errors.push('Published year cannot be in the future');
 }
 if (input.rating && (input.rating < 0 || input.rating > 5)) {
 errors.push('Rating must be between 0 and 5');
 }
 if (errors.length > 0) {
 throw newGraphQLError('Validation failed', {
 extensions: {
 code: 'BAD_USER_INPUT',
 validationErrors: errors,
 },
 });
 }
}
// Format errors globally before sending to the clientconst server = newApolloServer({
 typeDefs,
 resolvers,
 formatError: (formattedError, error) => {
 // Log internal errors for debuggingif (formattedError.extensions?.code === 'INTERNAL_SERVER_ERROR') {
 console.error('Internal error:', error);
 // Don't expose internal details to the clientreturn {
 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.

src/App.tsx (React + Apollo Client)
import {
 ApolloClient,
 InMemoryCache,
 ApolloProvider,
 useQuery,
 useMutation,
 gql,
} from'@apollo/client';
// Initialize the clientconst client = newApolloClient({
 uri: 'http://localhost:4000',
 cache: newInMemoryCache(),
 headers: {
 authorization: `Bearer ${localStorage.getItem('token')}`,
 },
});
// Define queries as tagged template literalsconst 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 booksfunctionBookList({ genre }) {
 const { loading, error, data, refetch } = useQuery(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>;
 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>
 ))}
 </div>
 );
}
// Wrap your app with ApolloProviderfunctionApp() {
 return (
 <ApolloProvider client={client}><BookList genre="Literary Fiction" /></ApolloProvider>
 );
}

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 depth limiting. graphql-depth-limit takes minutes to set up and prevents clients from crafting queries that flatten your server. DataLoader solves N+1. Both are non-negotiable.

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.

Nisha Agarwal

Nisha Agarwal

Frontend Developer & Design Systems Architect

Nisha is a frontend specialist and design system architect who creates stunning UIs with CSS and React. She writes about UI patterns and component architecture.