Every graph project reaches the same moment: the Cypher works, the model is right, and now a product team needs an API. The temptation is to hand-write a REST service that wraps a driver session per endpoint. Six months later that service is three thousand lines of string-built Cypher, an N+1 problem in every list endpoint, and the only person who can change it is the one who wrote it.
The Neo4j GraphQL Library takes the other road: you declare your graph as a GraphQL schema, and it generates the queries, mutations, filters, pagination, and — critically — a single Cypher statement per request. This tutorial builds a working API on a small movie graph, adds JWT authorization, escapes the generated-resolver box with @cypher, and covers what changes when you deploy it as an Aura GraphQL Data API instead of your own server.
Prerequisites
- A Neo4j 2025.x or 2026.x instance (Docker setup here, or Aura)
- Node.js 20+
- Familiarity with Cypher; no prior GraphQL experience assumed
mkdir graph-api && cd graph-api && npm init -y
npm i @neo4j/graphql graphql neo4j-driver @apollo/server
Step 1 — the schema is the model
Type definitions describe nodes; the @relationship directive describes edges, including direction and type. Nothing else is required to get a full API.
type Movie @node {
title: String!
released: Int
tagline: String
actors: [Person!]! @relationship(type: "ACTED_IN", direction: IN, properties: "ActedIn")
directors: [Person!]! @relationship(type: "DIRECTED", direction: IN)
}
type Person @node {
name: String!
born: Int
actedIn: [Movie!]! @relationship(type: "ACTED_IN", direction: OUT, properties: "ActedIn")
}
type ActedIn @relationshipProperties {
roles: [String!]
}
Three things to notice, because they are where most teams trip:
@nodeis now required on every type backed by a node (this became mandatory in v7 of the library; older tutorials omit it).- Relationship properties live in their own type marked
@relationshipProperties, referenced by name from both ends. - Direction is declared per field.
Movie.actorsisdirection: INbecause the relationship points fromPersontoMovie. Getting this backwards produces an API that returns empty lists and no error — check it first when something comes back blank.
Step 2 — serve it
import { Neo4jGraphQL } from "@neo4j/graphql";
import { ApolloServer } from "@apollo/server";
import { startStandaloneServer } from "@apollo/server/standalone";
import neo4j from "neo4j-driver";
import { readFileSync } from "node:fs";
const typeDefs = readFileSync("./schema.graphql", "utf8");
const driver = neo4j.driver(
process.env.NEO4J_URI,
neo4j.auth.basic(process.env.NEO4J_USER, process.env.NEO4J_PASSWORD)
);
const neoSchema = new Neo4jGraphQL({ typeDefs, driver });
const schema = await neoSchema.getSchema();
const server = new ApolloServer({ schema });
const { url } = await startStandaloneServer(server, {
context: async ({ req }) => ({ req }), // needed for auth in step 4
listen: { port: 4000 },
});
console.log(`API ready at ${url}`);
That is the whole server. Open the sandbox and you already have movies, people, createMovies, updateMovies, deleteMovies, connection fields, filtering, sorting and aggregation — generated from those twenty lines of schema.
query {
movies(where: { released: { gte: 1999 } }, sort: [{ released: DESC }], limit: 5) {
title
released
actors(limit: 3) {
name
actedInConnection(first: 1) { edges { properties { roles } } }
}
}
}
Step 3 — read the generated Cypher before you trust it
This is the step that separates an API you can operate from one you merely deployed. The library can log exactly what it sends to the database:
const neoSchema = new Neo4jGraphQL({
typeDefs,
driver,
debug: true, // or DEBUG=@neo4j/graphql:* in the environment
});
Run the query above and you will see one statement, roughly:
MATCH (this:Movie)
WHERE this.released >= $param0
CALL {
WITH this
MATCH (this)<-[:ACTED_IN]-(this1:Person)
WITH this1 LIMIT $param1
RETURN collect({ name: this1.name }) AS this2
}
RETURN this { .title, .released, actors: this2 } AS this
ORDER BY this.released DESC
LIMIT $param3
One round trip, nested subqueries, parameterised. That is the whole argument for this library over a hand-rolled resolver layer: GraphQL's classic N+1 problem does not arise, because nothing resolves field-by-field.
What you must still do is index the properties you filter and sort on. The generated Cypher is only as fast as the plan behind it:
CREATE CONSTRAINT movie_title IF NOT EXISTS FOR (m:Movie) REQUIRE m.title IS UNIQUE;
CREATE INDEX movie_released IF NOT EXISTS FOR (m:Movie) ON (m.released);
Take a slow query's generated statement, run it under PROFILE, and read the plan the way we walk through in Tuning Slow Cypher. Almost every "GraphQL is slow" ticket we get handed is a missing index or an unbounded list field.
Step 4 — authentication and authorization in the schema
Auth belongs next to the data, not in middleware that forgets a case. Configure a JWT verifier, then annotate types.
import { Neo4jGraphQL } from "@neo4j/graphql";
const neoSchema = new Neo4jGraphQL({
typeDefs,
driver,
features: {
authorization: { key: process.env.JWT_SECRET }, // or { url: "https://.../jwks.json" }
},
});
type JWT @jwt {
roles: [String!]!
}
type Movie @node
@authentication(operations: [CREATE, UPDATE, DELETE])
@authorization(
validate: [
{ operations: [CREATE, UPDATE, DELETE], where: { jwt: { roles_INCLUDES: "editor" } } }
]
) {
title: String!
released: Int
}
type Review @node
@authorization(
filter: [{ where: { node: { author: { id: "$jwt.sub" } } } }]
) {
body: String!
author: Person! @relationship(type: "WROTE", direction: IN)
}
validate rejects the operation; filter silently narrows results — the difference matters. A filter rule is compiled straight into the WHERE clause of the generated Cypher, so a user asking for reviews gets only their own, with no extra query and no chance of a resolver forgetting to apply it.
For defence in depth, pair this with database-level RBAC so that even a bug in the API layer cannot read what the connecting user must not see — see Multi-Tenant Neo4j.
Step 5 — @cypher for everything the generator will not do
Recommendations, path finding, aggregation across three hops, calls into GDS: express them as Cypher and expose them as fields.
type Movie @node {
title: String!
similar(limit: Int = 5): [Movie!]!
@cypher(
statement: """
MATCH (this)<-[:ACTED_IN]-(:Person)-[:ACTED_IN]->(rec:Movie)
WHERE rec <> this
RETURN rec, count(*) AS shared
ORDER BY shared DESC
LIMIT $limit
"""
columnName: "rec"
)
}
type Query {
shortestPathBetween(from: String!, to: String!): [Person!]!
@cypher(
statement: """
MATCH (a:Person {name: $from}), (b:Person {name: $to})
MATCH p = shortestPath((a)-[:ACTED_IN*..8]-(b))
UNWIND nodes(p) AS n
WITH n WHERE n:Person
RETURN n
"""
columnName: "n"
)
}
columnName tells the library which column of your statement to project. Two rules of thumb: keep @cypher statements read-only unless you genuinely need a custom mutation, and always bound your variable-length patterns (*..8, never *) — an unbounded traversal exposed on a public API is a denial-of-service endpoint with a nice schema.
Step 6 — Aura GraphQL Data API vs. self-hosted
Aura can host the API for you: upload type definitions, and Neo4j runs the GraphQL endpoint next to the database with no server of yours in the path. Use it when your schema plus @cypher fields cover the requirements and you want one less thing to operate. Self-host the library when you need custom resolvers alongside the generated ones, federation with other subgraphs, bespoke JWT handling, or middleware such as rate limiting and request logging that you control. The type definitions are the same either way, so this is a reversible decision — start hosted, move into your own Apollo server if and when you need to.
Production checklist
- Depth and complexity limits. GraphQL lets a client request
actors { actedIn { actors { actedIn ... } } }. Add a depth limiter and a cost rule; unbounded nesting is the most common outage cause for this stack. - Default limits per field.
@limit(default: 20, max: 100)on your node types stops a client from asking for every node in the database. - A single driver, reused. Create it once at process start, close it on shutdown; never per request.
- Read replicas for read traffic. Pass
sessionConfig: { defaultAccessMode: neo4j.session.READ }in the context for queries you know are read-only. - Introspection off in production, and persisted queries on if the client is your own app.
- Schema in CI. Diff the generated GraphQL schema on every commit so a
@relationshipdirection fix does not silently break a client.
Where this fits
A GraphQL layer over Neo4j is the fastest path from a good graph model to a product team consuming it — and the fastest path to a fragile API if the schema is written without a Cypher-shaped mental model behind it. Most of the work in the engagements we run is upstream of GraphQL: getting the property-graph model right so that the API you generate from it is the API you actually want.
If you are standing up a graph-backed API and want senior Neo4j eyes on the model, the authorization rules, or the query plans behind your busiest fields, get in touch — that is exactly what our Neo4j consultants do.