GraphQL with Express

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Express 5.3 (npm express 5.3.0, published 2026-10-09) on Node.js 18 or later, the minimum for Express 5, and it states where Express 4 (latest 4.22.3) behaves differently. The GraphQL packages were checked against the package registry on 2026-10-29: @apollo/server 5.5.1 and the Express 5 integration @as-integrations/express5 1.1.2, whose peer dependencies are express ^5.0.0 and @apollo/server ^4 or ^5. Check the Node.js versions that your Apollo Server release supports before choosing a runtime, because they can be higher than the Express minimum.

GraphQL itself (schemas, resolvers, the wire format) is documented in the GraphQL section of this site, and this page does not repeat it. What belongs here is the framework layer: how a GraphQL server is mounted as one more route of an Express application, in which order the middleware must run, how the Express request becomes the GraphQL context, and which Express-level concerns (CORS, body parsing, errors, shutdown) change when the route is a GraphQL endpoint. The examples use ESM and the node: prefix for built-in modules, and contain placeholders only, never real secrets. They extend the Bookshelf API that grows across this section.

Two ways to serve GraphQL from Express

Express does not know GraphQL. A GraphQL server is a function that receives an HTTP request and writes an HTTP response, which is exactly what an Express middleware is, so any server that exposes such a function can be mounted. Two choices are common:

Option What it is When to choose it

Apollo Server 5 with @as-integrations/express5

A full GraphQL server (plugins, caching hints, federation, landing page, usage reporting) whose expressMiddleware function adapts it to Express.

Most applications: batteries included, a large ecosystem, and the same server code can move to another framework by swapping the integration package.

graphql-http

The small reference implementation of the GraphQL over HTTP specification, with a createHandler function for Express (and other frameworks). It executes with graphql-js and has no plugins.

Minimal services, conformance testing, or when the schema and execution pipeline are assembled by hand.

Both rely on the same graphql package, and both produce the response forms described in Serving GraphQL over HTTP (a single endpoint, POST with a JSON body, the application/graphql-response+json media type, status codes). The rest of the page treats Apollo Server first, because it is what the book-based material uses, and graphql-http after it.

Apollo Server 5 on Express 5

Apollo Server 5 does not ship an Express integration. Since Apollo Server 4 the framework adapters are separate packages, and the Express 5 one is @as-integrations/express5. For Express 4 there is a sibling package, @as-integrations/express4. Install the server, the integration and the graphql peer:

npm install @apollo/server @as-integrations/express5 graphql express cors

The server object is created first, started with await server.start(), and only then wrapped by expressMiddleware. Calling expressMiddleware before the server is started throws, because the middleware needs the server’s internal state.

// graphql-server.mjs
import { createServer } from 'node:http';
import cors from 'cors';
import express from 'express';
import { ApolloServer } from '@apollo/server';
import { expressMiddleware } from '@as-integrations/express5';
import { ApolloServerPluginDrainHttpServer } from '@apollo/server/plugin/drainHttpServer';

const books = [
  { id: '1', title: 'The Pragmatic Programmer', author: 'Placeholder Author' },
  { id: '2', title: 'Designing Data-Intensive Applications', author: 'Another Author' },
];

const typeDefs = /* GraphQL */ `
  type Book {
    id: ID!
    title: String!
    author: String!
  }

  type Query {
    books: [Book!]!
    book(id: ID!): Book
  }
`;

const resolvers = {
  Query: {
    books: () => books,
    book: (_parent, { id }) => books.find((book) => book.id === id) ?? null,
  },
};

const app = express();
const httpServer = createServer(app);

const apollo = new ApolloServer({
  typeDefs,
  resolvers,
  // Lets in-flight requests finish when the HTTP server is closed.
  plugins: [ApolloServerPluginDrainHttpServer({ httpServer })],
});
await apollo.start();

app.use(
  '/graphql',
  cors({ origin: ['http://localhost:5173'] }),
  express.json({ limit: '100kb' }),
  expressMiddleware(apollo),
);

app.get('/health', (req, res) => {
  res.json({ status: 'ok' });
});

httpServer.listen(4000, () => {
  console.log('GraphQL endpoint on http://localhost:4000/graphql');
});

Four details of this listing matter in practice.

  1. The middleware order is part of the contract. expressMiddleware reads the already parsed req.body, so express.json() must run before it on the same path; in Express 5, req.body is undefined when no parser ran, and Apollo answers with an error asking for a JSON body. The CORS middleware goes first so that the preflight OPTIONS request is answered before any body is parsed.

  2. The route is mounted with app.use('/graphql', …), not app.get or app.post. Apollo needs GET (queries and the landing page), POST and OPTIONS on the same path. Do not mount it on a path pattern that contains a bare *: Express 5 rejects it (see Route Paths and Parameters).

  3. ApolloServerPluginDrainHttpServer needs the http.Server, which is why the code builds it with createServer(app) instead of calling app.listen. It stops accepting connections and lets running operations finish when apollo.stop() is called, so it pairs with the shutdown sequence in Health Checks and Graceful Shutdown.

  4. The body limit is set explicitly. Apollo does not parse the body itself, so the limit that protects the server from oversized documents is the one of express.json(). The default is 100kb; raise it only with a reason.

CORS, CSRF prevention and the other defaults

A GraphQL endpoint is a single URL that accepts arbitrary documents, so the request-level protections matter more than on a typical REST route.

  • CORS. Apollo Server 4 and 5 do not set CORS headers; without a cors middleware a browser application on another origin cannot call the endpoint. Pass an explicit origin list (an array, a function, or an environment-driven list) instead of calling cors() with no options, which allows every origin. The theory and the preflight rules are in CORS.

  • CSRF prevention. Apollo Server enables a check by default: whatever the method, a request must carry a Content-Type other than text/plain, application/x-www-form-urlencoded or multipart/form-data (a type that triggers a preflight), or a non-empty X-Apollo-Operation-Name or Apollo-Require-Preflight header. This blocks simple cross-site form submissions and GET requests that would otherwise execute a query with the user’s cookies. Leave it on unless you have a reason and a replacement. Browser clients that use GET for queries, which carry no Content-Type, must send one of the two headers.

  • Introspection and the landing page. Apollo disables introspection and replaces the embedded sandbox when NODE_ENV is production. Set introspection explicitly instead of relying on the environment variable, because a forgotten variable silently exposes the schema.

  • Batching. HTTP batching (an array of operations in one request) is off by default. Turn it on only together with a limit on the batch size, as explained in Security and Demand Control.

Building the context from the Express request

The GraphQL context is the per-request object that every resolver receives as its third argument. It is where the Express world meets the GraphQL world: the integration calls your context function with an object that contains the Express req and res, and whatever the function returns becomes the context. The function runs once per operation, so it is the right place to authenticate the caller, create per-request data loaders and attach a request-scoped logger.

// graphql-context.mjs
import { createServer } from 'node:http';
import { randomUUID } from 'node:crypto';
import cors from 'cors';
import DataLoader from 'dataloader';
import express from 'express';
import { GraphQLError } from 'graphql';
import { ApolloServer } from '@apollo/server';
import { expressMiddleware } from '@as-integrations/express5';
import { ApolloServerPluginDrainHttpServer } from '@apollo/server/plugin/drainHttpServer';

// Placeholder: in a real application this verifies a signed token (for example
// with the jose package) and returns the claims, or null when it is invalid.
async function verifyAccessToken(token) {
  return token === 'placeholder-valid-token' ? { sub: 'user-1', role: 'reader' } : null;
}

// Placeholder repository standing in for the database layer of the Bookshelf API.
const reviewRepository = {
  async findByBookIds(ids) {
    return ids.map((id) => ({ bookId: id, rating: 5, text: 'Placeholder review' }));
  },
};

const typeDefs = /* GraphQL */ `
  type Review {
    bookId: ID!
    rating: Int!
    text: String!
  }

  type Query {
    me: String
    reviewsFor(bookId: ID!): [Review!]!
  }
`;

const resolvers = {
  Query: {
    me: (_parent, _args, context) => {
      if (!context.user) {
        throw new GraphQLError('You must be signed in', {
          extensions: { code: 'UNAUTHENTICATED', http: { status: 401 } },
        });
      }
      return context.user.sub;
    },
    reviewsFor: async (_parent, { bookId }, context) => {
      // Batched with every other call made during the same operation.
      const review = await context.loaders.reviewsByBook.load(bookId);
      return review ? [review] : [];
    },
  },
};

async function buildContext({ req }) {
  const header = req.get('authorization') ?? '';
  const [scheme, token] = header.split(' ');
  const user = scheme === 'Bearer' && token ? await verifyAccessToken(token) : null;

  return {
    user,
    requestId: req.get('x-request-id') ?? randomUUID(),
    loaders: {
      reviewsByBook: new DataLoader(async (ids) => {
        const rows = await reviewRepository.findByBookIds(ids);
        return ids.map((id) => rows.find((row) => row.bookId === id));
      }),
    },
  };
}

const app = express();
const httpServer = createServer(app);
const apollo = new ApolloServer({
  typeDefs,
  resolvers,
  plugins: [ApolloServerPluginDrainHttpServer({ httpServer })],
});
await apollo.start();

app.use(
  '/graphql',
  cors({ origin: ['http://localhost:5173'] }),
  express.json({ limit: '100kb' }),
  expressMiddleware(apollo, { context: buildContext }),
);

httpServer.listen(4000);

Rules that keep the context correct:

  • Authenticate, do not authorize, in the context function. Reading the token and resolving the user belongs here. Deciding whether the user may see a field belongs in the resolvers or a directive layer (see Authorization). Throwing from the context function fails the whole request, so return null for an anonymous caller and let each resolver decide.

  • Reuse the Express authentication rather than duplicating it. If an earlier middleware already populated req.user (for example Passport, as in Authentication and Authorization), read that value in the context function instead of verifying the credential a second time.

  • Create one data loader per request. A loader caches results; sharing one across requests leaks data between users. The N+1 problem and batching are explained in Performance and N+1.

  • Do not stash res in the context to write headers from resolvers casually. It couples resolvers to the transport. When a resolver must set a cookie or a header, pass a small function through the context instead.

  • Errors become 200 responses unless told otherwise. A GraphQLError thrown by a resolver is reported in the errors array of a normal response. The http extension used above is how Apollo Server lets a resolver or the context function change the status code. The error format and what to hide in production are in Response and Error Handling.

Express errors around a GraphQL route

Errors inside resolvers never reach Express error-handling middleware: Apollo catches them and writes a GraphQL error response. Errors that happen outside Apollo do reach Express, and for a GraphQL route there are two to handle:

  • A body that is not valid JSON makes express.json() call next(err) with a 400 error before Apollo runs. The default Express handler answers with an HTML page, which a GraphQL client cannot parse. Register an error handler after the /graphql route that answers with JSON (the pattern is in Error Handling).

  • A payload larger than the configured limit produces a 413 error in the same way.

// graphql-errors.mjs
import express from 'express';

const app = express();

// ... app.use('/graphql', cors(), express.json(), expressMiddleware(apollo)); ...

// Registered after the GraphQL route: errors raised by express.json() end up here.
app.use('/graphql', (err, req, res, next) => {
  if (res.headersSent) {
    return next(err);
  }
  const status = err.status ?? err.statusCode ?? 500;
  res
    .status(status)
    .type('application/json')
    .json({
      errors: [
        {
          message: status >= 500 ? 'Internal server error' : err.message,
          extensions: { code: status === 413 ? 'PAYLOAD_TOO_LARGE' : 'BAD_REQUEST' },
        },
      ],
    });
});

graphql-http as a minimal alternative

graphql-http is the reference implementation of the GraphQL over HTTP specification, maintained in the GraphQL organization. It exports one handler factory per framework; the Express one is imported from graphql-http/lib/use/express. The handler reads the request body itself, and also accepts a body that express.json() parsed earlier, so the middleware order is less strict than with Apollo. It has no plugins, no landing page and no caching hints: an application that needs those should use Apollo Server.

// graphql-http-server.mjs
import cors from 'cors';
import express from 'express';
import { buildSchema } from 'graphql';
import { createHandler } from 'graphql-http/lib/use/express';

const schema = buildSchema(`
  type Query {
    hello: String!
  }
`);

const rootValue = {
  hello: () => 'Hello from the Bookshelf API',
};

const app = express();

app.all(
  '/graphql',
  cors({ origin: ['http://localhost:5173'] }),
  createHandler({
    schema,
    rootValue,
    // The first argument is graphql-http's own request wrapper ({ url, method, headers, body, raw, context }),
    // not the Express request: the Express request is req.raw.
    context: (req) => ({ requestId: req.raw.get('x-request-id') ?? null }),
  }),
);

app.listen(4000, (error) => {
  if (error) {
    throw error;
  }
});

Differences from Apollo Server that matter when choosing:

  • The handler is mounted with app.all, because it implements the method rules of the specification itself and answers unsupported methods with 405.

  • It follows the specification strictly: a response with Accept: application/graphql-response+json uses the status codes of that media type, while the legacy application/json always answers 200 with an error payload.

  • There is no built-in CSRF prevention, no landing page, no usage reporting and no plugin system. CSRF protection, depth limits and logging are the application’s responsibility.

  • Subscriptions are not part of this package; graphql-ws (over WebSocket) or Server-Sent Events handle them. See Subscriptions and Real Time: WebSockets and SSE.

Security and demand control

Mounting the endpoint is five lines; running it safely is the larger part of the work. The transport-level concerns are covered by the general Express security page (Security Best Practices: Helmet, rate limiting, TLS). The GraphQL-specific ones, where a single request can ask for an unbounded amount of work, are in Security and Demand Control. Mapped to an Express application:

Concern Where it is applied Notes

Request size

express.json({ limit }) before expressMiddleware

Bounds the size of the query document and variables.

Rate limiting

A middleware such as express-rate-limit in front of the /graphql route

Counts HTTP requests, not cost; one request can contain many aliased fields, so combine it with cost limits.

Depth, breadth and alias limits, query cost

validationRules of the Apollo Server options, or a plugin

Rejects an expensive document before any resolver runs.

Persisted operations and allowlisting

Apollo’s persisted-queries support, or a custom plugin

The strongest control for first-party clients: the server only runs documents it already knows.

Execution timeouts

A plugin, an AbortSignal passed through the context, or a reverse proxy timeout

Express timeouts do not stop a resolver that is already running.

Introspection and field suggestions

introspection option; a suggestion-hiding plugin or validation rule

Disable in production unless the API is public by design.

Error redaction

formatError option

Do not leak stack traces or database messages to clients.

For authentication and the choice of token, see Authentication and Authorization and, for the protocols behind them, the OAuth section.

Testing the endpoint

A GraphQL route is an ordinary HTTP route, so the techniques of Testing apply: export the app (or a factory that builds it) without calling listen, and send requests with supertest. Apollo Server also offers server.executeOperation to run an operation without HTTP, which is useful for resolver tests that do not need the middleware chain. A quick manual check with curl:

curl -s http://localhost:4000/graphql \
  -H 'content-type: application/json' \
  -H 'authorization: Bearer placeholder-valid-token' \
  -d '{"query":"{ me }"}'

Where the books are outdated

The GraphQL chapter of the requester-provided Node.js and Express book (Bugl) targets Apollo Server 4 mounted on Express 4, and its integration code no longer works with current releases.

What the book relies on Current state

The Apollo Server 4 Express integration imported from the @apollo/server/express4 entry point

Apollo Server 4 reached end of life in January 2026 (check Apollo’s version policy for the exact status). Apollo Server 5 removed the bundled Express integration; use @as-integrations/express5 with Express 5, or @as-integrations/express4 if the application stays on Express 4. The package is a separate dependency with its own peer ranges.

Express 4 as the host framework

Express 5 is the latest release since 2025-03-31. The integration for Express 5 requires express ^5.0.0.

A permissive cors() call and no body limit

Pass an explicit origin list and an explicit express.json({ limit }); both are security controls for a GraphQL endpoint.

Server start order taken for granted

await apollo.start() is required before expressMiddleware; with ESM, top-level await is available and CommonJS examples need an async wrapper.

Playground or sandbox left enabled

Introspection and the embedded explorer are production risks; configure introspection explicitly.

No depth, cost or batching limits, no persisted operations

See the demand-control layers in Security and Demand Control.

Callbacks, var and CommonJS throughout

Current examples use ESM, async and await; both module systems work with Express 5.

Common mistakes

  • Mounting expressMiddleware before express.json(), or before await apollo.start().

  • Calling cors() without options, which allows every origin.

  • Mounting the route with app.post only: the landing page, GET queries and the preflight then fail.

  • Sharing a data loader, or any per-user object, across requests instead of creating it in the context function.

  • Verifying the credential in the context function and again in a resolver, or trusting a role sent in the query.

  • Relying on NODE_ENV alone to hide introspection.

  • Forgetting to register a JSON error handler, so malformed bodies produce HTML.

  • Not draining the HTTP server on shutdown, which drops in-flight operations on every deploy.