Skip to content
Express.js ex api 5 min read

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/express5 works with Express 4 and 5. If you are still on the legacy express-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.

ConcernGraphQLREST
EndpointsOne (/graphql)Many (one per resource)
Over/under-fetchingClient picks exact fieldsFixed response shapes
HTTP cachingHard (POST, single URL)Easy (GET + URL + ETag)
VersioningEvolve schema, deprecate fieldsURL/header versions
Error handling200 with errors arrayHTTP status codes
Learning curveSchema + resolvers + toolingFamiliar HTTP verbs
File uploads / binariesAwkward (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 200 even for field errors, surfacing problems in an errors array. Don’t rely on status codes for GraphQL failures — inspect the body, and handle partial responses where data and errors both appear.

Best Practices

  • Use Apollo Server 4 (or graphql-yoga) over the deprecated express-graphql for any new GraphQL endpoint.
  • Put per-request concerns — auth, the current user, DB handles — in the context function, 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 @deprecated directive 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.
Last updated June 14, 2026
Was this helpful?