StackPractices
intermediate By Mathias Paulenko

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

Topics: api

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

flowchart diagram: Client query

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 startStandaloneServer for expressMiddleware from @apollo/server/express4 when 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 (or nodeEnv: '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-inspector and block PRs that remove fields or change types without a deprecation period.

Common Mistakes

  • A process-wide DataLoader. Its cache is a Map that 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
  • @cacheControl set on cacheable fields
  • Error formatting masks internal details (formatError or includeStacktraceInErrorResponses: 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/@defer handle 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:

ComponentCostNotes
Apollo Server (self-hosted)$0Open source, runs on any Node host
Apollo GraphOS$0-free tierSchema registry and basic metrics; paid tiers add tracing and SSO
Redis (subscriptions + caching)~$10-75Depends on instance size and provider
A single t3.small-class VM~$10-15Handles 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 assertValidSchema and graphql-inspector diff in CI so a breaking change can’t merge quietly.
  • Cache headers: send a query annotated with @cacheControl and check the Cache-Control header 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

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.