Build a GraphQL API with Apollo Server and TypeScript
How to build a production-ready GraphQL API using Apollo Server, TypeScript, and DataLoader to solve the N+1 query problem
Overview
GraphQL lets clients ask for exactly the data they need in a single query, and Apollo Server is still the most common way to serve a schema from Node. This recipe builds a small but real API: schema-first type definitions, resolvers wired to a database, DataLoader batching done the right way (per request, not per process), token authentication through context, and a @auth directive that actually compiles.
When to Use This
- Clients need flexible queries over a complex domain model
- You want to cut the over-fetching and under-fetching common in REST APIs
- Real-time updates via subscriptions are a requirement
Prerequisites
- Node.js 18+
- Basic GraphQL schema syntax
Solution
1. Install Dependencies
npm install @apollo/server graphql graphql-tag dataloader @graphql-tools/utils
npm install -D @types/node typescript
2. Define the Schema
// schema.ts
import { gql } from 'graphql-tag';
export const typeDefs = gql`
directive @auth on FIELD_DEFINITION
type User {
id: ID!
email: String!
name: String!
posts: [Post!]!
}
type Post {
id: ID!
title: String!
content: String!
author: User!
}
type Query {
user(id: ID!): User
users(limit: Int = 10): [User!]!
posts: [Post!]!
}
type Mutation {
createPost(title: String!, content: String!): Post! @auth
}
`;
3. Implement Resolvers with Request-Scoped DataLoader
The trap here is creating the loader once and reusing it across requests. DataLoader keeps an internal cache, so a shared instance can hand a stale User to a different request. Build the loader inside context so every request gets a fresh one.
// resolvers.ts
import type { GraphQLContext } from './context';
export const resolvers = {
Query: {
user: (_: unknown, { id }: { id: string }, ctx: GraphQLContext) =>
ctx.db.users.findById(id),
users: (_: unknown, { limit }: { limit: number }, ctx: GraphQLContext) =>
ctx.db.users.findMany({ take: limit }),
posts: (_: unknown, __: unknown, ctx: GraphQLContext) => ctx.db.posts.findMany(),
},
Mutation: {
createPost: (
_: unknown,
args: { title: string; content: string },
ctx: GraphQLContext,
) => ctx.db.posts.create({ ...args, authorId: ctx.user!.id }),
},
Post: {
// Batched: N posts still produce one user query per request
author: (post: { authorId: string }, _: unknown, ctx: GraphQLContext) =>
ctx.loaders.users.load(post.authorId),
},
User: {
posts: (user: { id: string }, _: unknown, ctx: GraphQLContext) =>
ctx.db.posts.findMany({ where: { authorId: user.id } }),
},
};
4. Create the Context and the Server
// context.ts
import DataLoader from 'dataloader';
import { db, verifyToken } from './db';
export const createContext = async (authHeader?: string) => {
const token = authHeader?.replace('Bearer ', '');
const user = token ? await verifyToken(token) : null;
return {
db,
user,
loaders: {
users: new DataLoader<string, User | undefined>(async (ids) => {
const rows = await db.users.findMany({ where: { id: { in: [...ids] } } });
return ids.map((id) => rows.find((u) => u.id === id));
}),
},
};
};
export type GraphQLContext = Awaited<ReturnType<typeof createContext>>;
// server.ts
import { ApolloServer } from '@apollo/server';
import { startStandaloneServer } from '@apollo/server/standalone';
import { typeDefs } from './schema';
import { resolvers } from './resolvers';
import { createContext } from './context';
import { authDirective } from './auth';
// schemaTransform wires @auth into the executable schema
const schema = authDirective(typeDefs, resolvers);
const server = new ApolloServer({ schema });
const { url } = await startStandaloneServer(server, {
listen: { port: 4000 },
context: async ({ req }) => createContext(req.headers.authorization),
});
console.log(`Server ready at ${url}`);
5. Authentication via a Schema Directive
// auth.ts
import { mapSchema, getDirective, MapperKind } from '@graphql-tools/utils';
import { defaultFieldResolver, GraphQLSchema } from 'graphql';
import { makeExecutableSchema } from '@graphql-tools/schema';
export const authDirective = (typeDefs: any, resolvers: any): GraphQLSchema =>
mapSchema(makeExecutableSchema({ typeDefs, resolvers }), {
[MapperKind.OBJECT_FIELD]: (fieldConfig) => {
const usesAuth = getDirective(
makeExecutableSchema({ typeDefs, resolvers }),
fieldConfig,
'auth',
)?.[0];
if (!usesAuth) return fieldConfig;
const { resolve = defaultFieldResolver } = fieldConfig;
fieldConfig.resolve = (source, args, context, info) => {
if (!context.user) throw new Error('Unauthorized');
return resolve(source, args, context, info);
};
return fieldConfig;
},
});
How It Works
The context factory runs once per HTTP request, which is exactly where DataLoader belongs. Inside a single request the loader batches every users.load(id) call into one database query and memoizes the result, so Post.author on fifty posts still costs one round-trip instead of fifty.
Variants
- Express middleware: swap
startStandaloneServerforexpressMiddlewarefrom@apollo/server/express4when the API shares a process with REST routes or needs custom middleware. - GraphQL Yoga: a lighter server with the same resolver model; a reasonable pick if you don’t need the Apollo plugin ecosystem.
- Apollo Federation: split the schema across services behind a gateway once you’ve got more than one GraphQL service worth owning. See the federation gateway recipe.
Best Practices
- Design schema-first. Write the contract before the resolvers; it forces you to think about what clients actually need instead of exposing your tables.
- Disable Sandbox in production. It exposes introspection and a query builder. Pass
introspection: false(ornodeEnv: 'production') in the server options. - Put request state in context. Auth, loaders, data sources — anything scoped to a request goes through
context, never module-level singletons. - Annotate cacheable fields with
@cacheControl(maxAge, scope) so Apollo Server and downstream CDNs can cache reads. - Diff the schema in CI with
graphql-inspectorand block PRs that remove fields or change types without a deprecation period.
Common Mistakes
- A process-wide DataLoader. Its cache is a
Mapthat lives as long as the instance; share it between requests and you serve one user’s data to another. Always create loaders inside the context factory. - Playground left on in production. Development defaults expose your schema and let anyone run arbitrary queries. Disable introspection and Sandbox before shipping.
- Resolvers returning full rows. If the client asks for two fields, fetching the whole row wastes bandwidth and CPU — select only what the selection set needs.
- No depth or cost limiting. A malicious or buggy client can nest queries until the server falls over. Cap depth (7-10 levels) and consider persisted queries for first-party clients.
- Skipping schema registration. Without a registry there’s no history of breaking changes or per-client usage. Apollo Studio’s free tier covers it.
Production Checklist
- Introspection and Sandbox disabled in production
- Loaders created per request in the context factory
- Query depth limited (max 7-10 levels)
- Persisted or safelisted queries enforced for first-party clients
-
@cacheControlset on cacheable fields - Error formatting masks internal details (
formatErrororincludeStacktraceInErrorResponses: false) - Health check at
/.well-known/apollo/server-health - Schema registered in Apollo Studio for change tracking
Scaling Considerations
- Resolver fan-out: resolvers for sibling fields can run in parallel, but a chain of nested resolvers serializes. A query fifty fields deep with 5 ms per resolver costs ~250 ms; flatten the shape or batch with loaders where the data allows it.
- Large result sets: GraphQL builds the whole response object in memory before serializing. Paginate past a few thousand items;
@stream/@deferhandle incremental delivery for slow leaves. - Running more than one node: the server keeps no session state, so you scale it by adding processes. Subscriptions break that picture: they need a shared pub/sub backend (Redis or NATS) so an event on one node reaches subscribers on the rest.
- Serverless cold starts: schema build and validation add real milliseconds to a Lambda cold start versus a warm Node process — pre-build the schema and keep the bundle small if you go that route.
Cost Estimation
Rough monthly figures for a mid-size API, as of late 2026 — check current pricing before budgeting:
| Component | Cost | Notes |
|---|---|---|
| Apollo Server (self-hosted) | $0 | Open source, runs on any Node host |
| Apollo GraphOS | $0-free tier | Schema registry and basic metrics; paid tiers add tracing and SSO |
| Redis (subscriptions + caching) | ~$10-75 | Depends on instance size and provider |
| A single t3.small-class VM | ~$10-15 | Handles a few thousand requests per second in this shape |
Self-hosting keeps the floor near the cost of the VM. The managed pieces earn their money when you need schema governance, usage analytics, or routing across subgraphs.
When Not to Use This Approach
- Plain CRUD over a flat model: five REST endpoints and an OpenAPI doc beat a GraphQL server with a tenth of the moving parts.
- Edge runtimes: Apollo Server expects Node.js. For Cloudflare Workers or Vercel Edge, look at Yoga or a thinner handler.
- Heavy binary payloads: uploads and downloads belong on a REST endpoint or object storage; GraphQL’s multipart spec is workable but rarely the right tool.
Testing Strategy
- Context injection: build a server instance with a mock context (auth, loaders, fake db) and assert resolvers see the right shape and fail cleanly when it’s missing.
- Schema validity: run
assertValidSchemaandgraphql-inspector diffin CI so a breaking change can’t merge quietly. - Cache headers: send a query annotated with
@cacheControland check theCache-Controlheader on the way out. - Subscription cleanup: open a WebSocket subscription, close it, and assert the server drops it from the pub/sub registry within a few seconds.
Troubleshooting
- 5xx errors under load: check rate limits, connection pools, and downstream timeouts.
- CORS errors in the browser: confirm allowed origins, methods, and headers. Preflight requests must return the right headers before the actual request.
- Unexpected 404s: verify route definitions, path parameters, and base paths. Watch for trailing slashes and URL encoding differences.
- Authentication failures: validate token expiry, signature algorithms, and clock skew. Log rejected tokens without exposing secrets.
- Slow responses: profile resolver latency per field; a single unbatched loader call usually explains the tail.
See Also
- Apollo Server documentation — official docs for v4 and v5; the upgrade between them mostly adjusts supported dependency versions
- DataLoader repository — the batching/caching utility behind the loader pattern used here
- GraphQL Inspector — schema diffing and breaking-change detection for CI
Frequently Asked Questions
Should I use Apollo Server or GraphQL Yoga?
Apollo Server has the largest plugin ecosystem and the Apollo Studio integration. Yoga is lighter and starts faster, which matters on edge runtimes and serverless. Both are production-ready; pick Yoga when startup time or bundle size is the constraint, Apollo when you want the tooling around it.
Does DataLoader belong in context or at module level?
Per request, in context. The loader caches results, and a shared cache can leak one user's data into another request. The context factory is the right scope — one instance per HTTP request.
How do I handle file uploads in GraphQL?
Use graphql-upload-minimal with multipart requests, or prefer a separate REST endpoint for large files — that's usually simpler and cheaper.
Do subscriptions work out of the box across several nodes?
Each node keeps its own in-memory pub/sub, so an event published on one never reaches subscribers connected to another. Point every node at a shared backend like Redis or NATS.
Do I need Apollo Studio to run Apollo Server?
No. The server is fully self-contained. Studio adds a schema registry, breaking-change detection, and per-client usage analytics — worth having in production, optional while prototyping.
What's the difference between persisted queries and safelisting?
Persisted queries let clients send a hash instead of the full query text, saving bandwidth and parse time. Safelisting goes further: only pre-registered queries execute at all, which blocks arbitrary expensive queries from unknown clients.
Related Resources
Batch and Cache Database Queries with GraphQL DataLoader
Use DataLoader to coalesce individual load requests into batched database calls, solving the N+1 query problem in GraphQL resolvers
RecipeDetect and Fix N+1 Queries in GraphQL Resolvers
Identify N+1 query problems in GraphQL resolvers using logging, DataLoader, and query analysis tools before they hit production
RecipeStructured GraphQL Errors with Extension Codes
Implement structured error handling in GraphQL with custom error classes, extension codes, and consistent error formatting for clients
RecipeMock GraphQL Resolvers for Frontend Development
Set up mocked GraphQL resolvers with Apollo Server so frontend teams can develop against a fake API before the backend is ready.
RecipeSet Up a GraphQL Federation Gateway with Apollo
Compose multiple GraphQL services into a single federated supergraph using Apollo Federation and a gateway that routes queries across subgraphs
RecipeReal-Time Data with GraphQL WebSocket Subscriptions
Implement GraphQL subscriptions over WebSockets with Apollo Server and PubSub for real-time updates pushed to connected clients