Skill v1.0.1
currentAutomated scan100/100+2 new
name: neo4j-graphql-skill description: Build and configure a GraphQL API backed by Neo4j using @neo4j/graphql v7 (current) or v5 (LTS). Covers Neo4jGraphQL constructor, getSchema(), assertIndexesAndConstraints(), type definitions with @node, @relationship (IN/OUT/UNDIRECTED), @cypher for custom resolvers, @authorization/@authentication for JWT/JWKS security, auto-generated queries/mutations, OGM programmatic access, subscriptions via CDC, and Apollo Federation. Use when writing typeDefs, securing fields, or wiring Neo4j to Apollo Server. Does NOT handle raw Cypher outside resolvers — use neo4j-cypher-skill. Does NOT cover Spring Data Neo4j entity mapping — use neo4j-spring-data-skill. version: 1.0.1 allowed-tools: Bash WebFetch
When to Use
- Creating a GraphQL API from a Neo4j graph schema with
@neo4j/graphql - Writing type definitions with
@relationship,@cypher,@authorizationdirectives - Using OGM for server-side programmatic Neo4j access (bypasses GraphQL auth)
- Configuring auto-generated queries, mutations, subscriptions
- Securing types/fields with JWT or JWKS-based
@authorizationrules - Migrating from v5/v6 to v7 (breaking changes below)
When NOT to Use
- Raw Cypher queries outside GraphQL resolvers →
neo4j-cypher-skill - Spring Data Neo4j / Java entity mapping →
neo4j-spring-data-skill - Generic GraphQL without Neo4j — outside scope
Version Matrix
| Version | Status | Notes | |
|---|---|---|---|
| v7 | Current | @node required; options removed; explicit eq syntax | |
| v5 | LTS | Older syntax; options: {limit, offset, sort} still valid |
Default to v7 unless codebase is on v5.
Step 1 — Install
npm install @neo4j/graphql neo4j-driver graphql @apollo/server
For subscriptions (CDC required):
npm install ws graphql-ws express body-parser cors
Step 2 — Minimal Server Setup
import { ApolloServer } from '@apollo/server';import { startStandaloneServer } from '@apollo/server/standalone';import { Neo4jGraphQL } from '@neo4j/graphql';import neo4j from 'neo4j-driver';const typeDefs = `#graphqltype Movie @node {id: ID! @idtitle: String!actors: [Person!]! @relationship(type: "ACTED_IN", direction: IN)}type Person @node {id: ID! @idname: String!movies: [Movie!]! @relationship(type: "ACTED_IN", direction: OUT)}`;const driver = neo4j.driver(process.env.NEO4J_URI,neo4j.auth.basic(process.env.NEO4J_USERNAME, process.env.NEO4J_PASSWORD));const neoSchema = new Neo4jGraphQL({ typeDefs, driver });// assertIndexesAndConstraints syncs @id → UNIQUE constraints; wrap in try/catchawait neoSchema.assertIndexesAndConstraints({ options: { create: true } });const server = new ApolloServer({ schema: await neoSchema.getSchema() });const { url } = await startStandaloneServer(server, {context: async ({ req }) => ({ token: req.headers.authorization }),listen: { port: 4000 },});
assertIndexesAndConstraints throws if constraints missing. Use { create: true } to auto-create, or run CREATE CONSTRAINT manually and retry.
Key Directives
@node (v7 required)
Every GraphQL type representing a Neo4j node must have @node. Without it, v7 ignores the type.
type Product @node {id: ID! @idname: String!}# Custom label (default = type name)type Article @node(labels: ["Post", "Content"]) {title: String!}
@relationship — Full Syntax
type Person @node {# direction: OUT = (this)-[:KNOWS]->(other)friends: [Person!]! @relationship(type: "KNOWS", direction: OUT)# direction: IN = (other)-[:ACTED_IN]->(this)actedIn: [Movie!]! @relationship(type: "ACTED_IN", direction: IN)# direction: UNDIRECTED = matches both directions (use sparingly — double-counts)colleagues: [Person!]! @relationship(type: "COLLEAGUE_OF", direction: UNDIRECTED)# Relationship with properties — reference an @relationshipProperties interfacereviews: [Movie!]! @relationship(type: "REVIEWED", direction: OUT, properties: "ReviewedProps")}interface ReviewedProps @relationshipProperties {rating: Int!date: Date}
Direction rule: OUT = arrow leaves this node. IN = arrow enters this node. Both sides of a relationship must declare opposite directions.
Querying Relationship Properties — Connection API
For each relationship with properties:, a {field}Connection field is auto-generated. Access rel properties via actorsConnection.edges.properties, not via actors:
query {movies(where: { title: { eq: "The Matrix" } }) {titleactorsConnection {edges {properties { role } # maps to @relationshipProperties interfacenode { name }}}}}
@cypher — Custom Resolver
type Person @node {name: String!# columnName must exactly match the RETURN alias — mismatch returns null silentlyfriendCount: Int@cypher(statement: "MATCH (this)-[:KNOWS]->(f:Person) RETURN count(f) AS friendCount"columnName: "friendCount")recommendedMovies: [Movie!]!@cypher(statement: """MATCH (this)-[:WATCHED]->(m:Movie)<-[:WATCHED]-(o:Person)-[:WATCHED]->(rec:Movie)WHERE NOT (this)-[:WATCHED]->(rec)RETURN rec"""columnName: "rec")}# @cypher on Query field — custom top-level querytype Query {topRatedMovies(limit: Int = 10): [Movie!]!@cypher(statement: "MATCH (m:Movie) WHERE m.rating IS NOT NULL RETURN m ORDER BY m.rating DESC LIMIT $limit"columnName: "m")}
this refers to the current node in field-level @cypher. Parameters are passed as $paramName.
@cypher — Field Arguments and extend type
# extend type adds computed fields without modifying the base type definitionextend type Movie @node {avgRating: Float@cypher(statement: "MATCH (this)<-[r:RATED]-(:User) RETURN avg(r.rating) AS result", columnName: "result")# Field arguments passed as Cypher params; always provide default to avoid nullrecommended(limit: Int = 3): [Movie!]!@cypher(statement: "MATCH (this)<-[:RATED]-(u:User)-[:RATED]->(rec:Movie) WITH rec, COUNT(u) AS score ORDER BY score DESC RETURN rec LIMIT $limit"columnName: "rec")}
@id and @timestamp
type Post @node {id: ID! @id # auto-generates UUID; creates UNIQUE constraintcreatedAt: DateTime! @timestamp(operations: [CREATE])updatedAt: DateTime @timestamp(operations: [CREATE, UPDATE])title: String!}
@alias — Map GraphQL field to Neo4j property
type User @node {id: ID! @idemail: String! @alias(property: "emailAddress") # GraphQL: email → DB: emailAddress}
Security — @authentication and @authorization
Step 1: Configure JWT in constructor
// Symmetric secretconst neoSchema = new Neo4jGraphQL({typeDefs,driver,features: {authorization: { key: process.env.JWT_SECRET },},});// JWKS endpoint (production)const neoSchema = new Neo4jGraphQL({typeDefs,driver,features: {authorization: {key: { url: 'https://myapp.com/.well-known/jwks.json' },},},});
Step 2: Pass token in context
context: async ({ req }) => ({ token: req.headers.authorization }),// Or pass pre-decoded JWT:context: async ({ req }) => ({ jwt: myDecodeJwt(req.headers.authorization) }),
Step 3: Apply @authentication and @authorization
# Require auth on all operations for a typetype Post @node@authentication@authorization(filter: [{ where: { node: { author: { id: { eq: "$jwt.sub" } } } } }]) {title: String!author: User! @relationship(type: "AUTHORED", direction: IN)}# requireAuthentication: false = allow public access without JWTtype Article @node@authorization(filter: [{ requireAuthentication: false, where: { node: { published: { eq: true } } } }{ where: { node: { author: { id: { eq: "$jwt.sub" } } } } }]) {title: String!published: Boolean!}# validate (throws error) vs filter (silently hides data)type BankAccount @node@authorization(validate: [{when: [BEFORE],where: { node: { owner: { id: { eq: "$jwt.sub" } } } }}]) {balance: Float!}# Role-based with custom JWT claimstype JWT @jwt {roles: [String!]! @jwtClaim(path: "myApp.roles")}type AdminReport @node@authentication(operations: [READ], jwt: { roles: { includes: "admin" } }) {data: String!}
filter vs validate: filter silently removes unauthorized data. validate throws an error. Use validate when data existence should not be revealed to unauthorized users.
BEFORE vs AFTER: CREATE supports only AFTER; READ supports only BEFORE.
Auto-Generated Operations
For each @node type, the library generates:
| Operation | Generated Name | Example | |
|---|---|---|---|
| Query all | {plural} | movies(where, sort, limit, offset) | |
| Cursor pagination | {plural}Connection | moviesConnection(first, after, where, sort) | |
| Create | create{Plural} | createMovies(input: [MovieCreateInput!]!) | |
| Update | update{Plural} | updateMovies(where, update) | |
| Delete | delete{Plural} | deleteMovies(where, delete) |
v7 Filter Syntax (explicit eq)
# v7: explicit eq requiredquery {movies(where: { title: { eq: "The Matrix" } }) {titleactors { name }}}# Sort and paginate (v7: direct args, not options wrapper)query {movies(sort: [{ title: ASC }], limit: 10, offset: 0) {title}}
Nested Mutations
mutation {createMovies(input: [{title: "Inception"actors: {create: [{ node: { name: "Leonardo DiCaprio" } }]connect: { where: { node: { name: { eq: "Joseph Gordon-Levitt" } } } }}}]) {movies { id title }}}
connectOrCreate was removed in v7. Use connect + create separately.
OGM — Programmatic Access
OGM bypasses GraphQL authorization — use only in trusted server-side contexts.
import { OGM } from '@neo4j/graphql-ogm';const ogm = new OGM({ typeDefs, driver });await ogm.init(); // must await before using modelsconst Movie = ogm.model('Movie');// findconst movies = await Movie.find({where: { title: { eq: 'The Matrix' } },selectionSet: `{ id title actors { name } }`,});// createconst { movies: created } = await Movie.create({input: [{ title: 'Dune', actors: { create: [{ node: { name: 'Timothée Chalamet' } }] } }],});// updateawait Movie.update({where: { id: { eq: movieId } },update: { title: { set: 'Dune: Part One' } },});// deleteawait Movie.delete({ where: { id: { eq: movieId } } });
Install separately: npm install @neo4j/graphql-ogm
Subscriptions (CDC Required)
Requires Neo4j CDC enabled in FULL mode. See CDC docs.
const neoSchema = new Neo4jGraphQL({typeDefs,driver,features: { subscriptions: true },});
Subscriptions auto-generate for each type:
subscription {movieCreated(where: { title: { eq: "The Matrix" } }) {createdMovie { title }}}# Also: movieUpdated, movieDeleted
Schema Control Directives
type ReadOnlyData @node @mutation(operations: []) { value: String! } # disable mutationstype HeavyDoc @node {id: ID! @idcontent: String! @filterable(byValue: false) @sortable(enabled: false) # perf guardtitle: String!}type Series @node @plural(value: "seriesList") { title: String! } # irregular plural fix
Common Errors
| Error | Cause | Fix | |
|---|---|---|---|
Type 'X' not found | Missing @node on type (v7) | Add @node to every node type | |
@cypher field returns null | columnName mismatch with RETURN alias | Match columnName exactly to RETURN alias | |
| Relationship direction mismatch | Both sides declare same direction | Inverse: if A has direction: OUT, B must have direction: IN | |
assertIndexesAndConstraints throws | @id constraint not in DB | Add { options: { create: true } } or run CREATE CONSTRAINT manually | |
| Auth not applied | JWT not in context | Pass token: req.headers.authorization in context function | |
| 0 results with valid data | v7 filter missing eq | Use { field: { eq: value } } not { field: value } | |
connectOrCreate not found | Removed in v7 | Use connect + create separately | |
| Memory errors on large mutations | Complex Cypher generation | Batch mutations; increase server.memory.heap.max_size | |
@subscription not generating | v7 requires explicit enable | Add features: { subscriptions: true } to constructor |
v6 → v7 Breaking Changes Summary
| v6 | v7 | |
|---|---|---|
@node optional | @node required on every node type | |
options: { limit, sort } | limit, sort as direct args | |
{ field: value } filter | { field: { eq: value } } | |
connectOrCreate nested mutation | Removed — use connect + create | |
directed arg on queries | queryDirection in @relationship | |
Single rel fields actor: Person | Must use list actors: [Person!]! | |
@private directive | Removed | |
@unique directive | Removed |
References
- Neo4j GraphQL Docs — full directive reference, migration guides
- GraphAcademy: GraphQL Basics — hands-on course
- GitHub: @neo4j/graphql — changelog, issues
- CDC Setup — required for subscriptions
Checklist
- [ ]
@nodeon every GraphQL type representing a Neo4j node (v7 hard requirement) - [ ]
@idon identity fields (triggersCREATE CONSTRAINTviaassertIndexesAndConstraints) - [ ]
assertIndexesAndConstraintscalled on startup with try/catch - [ ]
@relationshipdirection correct:OUT= arrow leaves this node,IN= arrow enters - [ ] Both sides of relationship declared with inverse directions
- [ ]
@cyphercolumnNamematches RETURN alias exactly - [ ] JWT secret or JWKS URL in
features.authorization.key; token passed in context - [ ]
@authorizationfilter vs validate chosen deliberately (silent hide vs thrown error) - [ ] v7: filters use explicit
{ field: { eq: value } }syntax - [ ] v7:
limit/sortpassed as direct query args (notoptionswrapper) - [ ] OGM:
await ogm.init()called before anyogm.model()usage - [ ] Subscriptions: CDC enabled in FULL mode before enabling
features.subscriptions - [ ]
.envholds credentials;.envin.gitignore