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 |
A full GraphQL server (plugins, caching hints, federation, landing page, usage reporting) whose |
Most applications: batteries included, a large ecosystem, and the same server code can move to another framework by swapping the integration package. |
|
The small reference implementation of the GraphQL over HTTP specification, with a |
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');
});
Source: Apollo Server: expressMiddleware
Four details of this listing matter in practice.
-
The middleware order is part of the contract.
expressMiddlewarereads the already parsedreq.body, soexpress.json()must run before it on the same path; in Express 5,req.bodyisundefinedwhen no parser ran, and Apollo answers with an error asking for a JSON body. The CORS middleware goes first so that the preflightOPTIONSrequest is answered before any body is parsed. -
The route is mounted with
app.use('/graphql', …), notapp.getorapp.post. Apollo needsGET(queries and the landing page),POSTandOPTIONSon the same path. Do not mount it on a path pattern that contains a bare*: Express 5 rejects it (see Route Paths and Parameters). -
ApolloServerPluginDrainHttpServerneeds thehttp.Server, which is why the code builds it withcreateServer(app)instead of callingapp.listen. It stops accepting connections and lets running operations finish whenapollo.stop()is called, so it pairs with the shutdown sequence in Health Checks and Graceful Shutdown. -
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 is100kb; 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
corsmiddleware 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 callingcors()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-Typeother thantext/plain,application/x-www-form-urlencodedormultipart/form-data(a type that triggers a preflight), or a non-emptyX-Apollo-Operation-NameorApollo-Require-Preflightheader. This blocks simple cross-site form submissions andGETrequests 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 useGETfor queries, which carry noContent-Type, must send one of the two headers. -
Introspection and the landing page. Apollo disables introspection and replaces the embedded sandbox when
NODE_ENVisproduction. Setintrospectionexplicitly 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
nullfor 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
resin 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
200responses unless told otherwise. AGraphQLErrorthrown by a resolver is reported in theerrorsarray of a normal response. Thehttpextension 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()callnext(err)with a400error before Apollo runs. The default Express handler answers with an HTML page, which a GraphQL client cannot parse. Register an error handler after the/graphqlroute that answers with JSON (the pattern is in Error Handling). -
A payload larger than the configured limit produces a
413error 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' },
},
],
});
});
Source: Express: Error handling
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;
}
});
Source: graphql-http on GitHub
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 with405. -
It follows the specification strictly: a response with
Accept: application/graphql-response+jsonuses the status codes of that media type, while the legacyapplication/jsonalways answers200with 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 |
|
Bounds the size of the query document and variables. |
Rate limiting |
A middleware such as |
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 |
|
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 |
Express timeouts do not stop a resolver that is already running. |
Introspection and field suggestions |
|
Disable in production unless the API is public by design. |
Error redaction |
|
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 }"}'
Source: Apollo Server: expressMiddleware
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 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 |
Express 4 as the host framework |
Express 5 is the |
A permissive |
Pass an explicit origin list and an explicit |
Server start order taken for granted |
|
Playground or sandbox left enabled |
Introspection and the embedded explorer are production risks; configure |
No depth, cost or batching limits, no persisted operations |
See the demand-control layers in Security and Demand Control. |
Callbacks, |
Current examples use ESM, |
Common mistakes
-
Mounting
expressMiddlewarebeforeexpress.json(), or beforeawait apollo.start(). -
Calling
cors()without options, which allows every origin. -
Mounting the route with
app.postonly: the landing page,GETqueries 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_ENValone 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.
References
-
Apollo Server, Express middleware API: https://www.apollographql.com/docs/apollo-server/api/express-middleware
-
Apollo Server, context: https://www.apollographql.com/docs/apollo-server/data/context
-
Apollo Server, drain HTTP server plugin: https://www.apollographql.com/docs/apollo-server/api/plugin/drain-http-server
-
Apollo Server, migrating to version 5: https://www.apollographql.com/docs/apollo-server/migration
-
@as-integrations/express5on npm: https://www.npmjs.com/package/@as-integrations/express5 -
graphql-httpon GitHub: https://github.com/graphql/graphql-http -
GraphQL over HTTP specification: https://graphql.github.io/graphql-over-http/
-
Express error handling: https://expressjs.com/en/guide/error-handling/
-
On this site: Serving GraphQL over HTTP, Security and Demand Control, CORS, Body Parsing