GraphQL with Express
GraphQL gives clients a single endpoint and lets them ask for exactly the fields they need, instead of you designing one URL per resource shape. Because GraphQL is transport-agnostic, it slots neatly into an existing Express app as ordinary middleware — you can run it on /graphql while your REST routes keep serving the rest of the app. This page sets up Apollo Server as Express middleware, defines a schema and resolvers, mounts it, and weighs GraphQL against REST so you can choose deliberately.
How GraphQL fits into Express
A GraphQL server is really just one HTTP handler. Clients POST a query string to a single endpoint, the server resolves it against a typed schema, and returns JSON shaped like the query. That means you can mount GraphQL as a single piece of Express middleware and leave everything else — auth, logging, CORS, REST routes — exactly as it is.
The modern, maintained choice is Apollo Server 4 with its Express integration. The older express-graphql package is deprecated, so prefer Apollo (or graphql-yoga) for new projects.
npm install @apollo/server @as-integrations/express5 graphql express cors
@as-integrations/express5works with Express 4 and 5. If you are still on the legacyexpress-graphql, migrate — it no longer receives updates and lags behind the GraphQL spec.
Defining a schema and resolvers
A schema declares the types, queries, and mutations your API exposes. It is the contract: clients can only ask for what the schema describes, and the server validates every request against it before a single resolver runs.
const typeDefs = `#graphql
type User {
id: ID!
firstName: String!
lastName: String!
posts: [Post!]!
}
type Post {
id: ID!
title: String!
authorId: ID!
}
type Query {
user(id: ID!): User
users: [User!]!
}
type Mutation {
createUser(firstName: String!, lastName: String!): User!
}
`;
Resolvers are the functions that produce data for each field. They are keyed by type and field name; every resolver receives (parent, args, context, info). Resolvers are commonly async, so you can await database calls directly. The posts resolver on User shows how a field resolves relations lazily — it only runs if the client actually requests posts.
const resolvers = {
Query: {
user: async (_parent, { id }) => db.users.find(id),
users: async () => db.users.all(),
},
Mutation: {
createUser: async (_parent, { firstName, lastName }) =>
db.users.create({ firstName, lastName }),
},
User: {
posts: async (user) => db.posts.byAuthor(user.id),
},
};
Mounting Apollo on Express
You create an ApolloServer with the schema, await server.start(), then attach it to a route with the expressMiddleware adapter. Because it is plain middleware, you control the path and can layer cors() and express.json() in front of it.
const express = require("express");
const cors = require("cors");
const { ApolloServer } = require("@apollo/server");
const { expressMiddleware } = require("@as-integrations/express5");
async function main() {
const app = express();
const server = new ApolloServer({ typeDefs, resolvers });
await server.start();
app.use(
"/graphql",
cors(),
express.json(),
expressMiddleware(server, {
context: async ({ req }) => ({ user: req.user, token: req.headers.authorization }),
})
);
// REST routes coexist on the same app
app.get("/health", (_req, res) => res.json({ ok: true }));
app.listen(4000, () => console.log("Ready at http://localhost:4000/graphql"));
}
main();
The context function runs once per request and is where you put per-request data — the authenticated user, a DB connection, or loaders. Resolvers read it as their third argument, which keeps auth and data access out of the resolver bodies themselves.
Querying the API
Clients send a POST with a JSON body containing query (and optional variables). The response mirrors the query’s shape under a top-level data key.
curl -X POST http://localhost:4000/graphql \
-H 'Content-Type: application/json' \
-d '{"query":"{ user(id: 42) { firstName posts { title } } }"}'
Output:
{
"data": {
"user": {
"firstName": "Ada",
"posts": [
{ "title": "Notes on the Analytical Engine" }
]
}
}
}
Notice the client asked for firstName and post title only — no over-fetching, and the relation was traversed in one round trip.
GraphQL vs REST
Neither is universally better; they make different trade-offs. GraphQL shines when clients have varied data needs or deeply nested relations; REST shines for simple, cacheable, resource-oriented APIs.
| Concern | GraphQL | REST |
|---|---|---|
| Endpoints | One (/graphql) | Many (one per resource) |
| Over/under-fetching | Client picks exact fields | Fixed response shapes |
| HTTP caching | Hard (POST, single URL) | Easy (GET + URL + ETag) |
| Versioning | Evolve schema, deprecate fields | URL/header versions |
| Error handling | 200 with errors array | HTTP status codes |
| Learning curve | Schema + resolvers + tooling | Familiar HTTP verbs |
| File uploads / binaries | Awkward (needs extensions) | Native |
A common pattern is to use both: GraphQL for rich client-driven reads, REST for webhooks, file transfer, and third-party integrations that expect plain HTTP.
GraphQL returns HTTP
200even for field errors, surfacing problems in anerrorsarray. Don’t rely on status codes for GraphQL failures — inspect the body, and handle partial responses wheredataanderrorsboth appear.
Best Practices
- Use Apollo Server 4 (or
graphql-yoga) over the deprecatedexpress-graphqlfor any new GraphQL endpoint. - Put per-request concerns — auth, the current user, DB handles — in the
contextfunction, not in individual resolvers. - Batch relation lookups with DataLoader to avoid the N+1 query problem when resolving lists with nested fields.
- Mount GraphQL as middleware on its own path so REST routes, CORS, and logging continue to work unchanged.
- Evolve, don’t version: add fields freely and mark removed ones with the
@deprecateddirective instead of cutting new endpoints. - Set a query depth/complexity limit in production so clients can’t craft expensive deeply nested queries that exhaust the server.
- Disable schema introspection and the GraphQL playground in production unless the API is intentionally public.