Introduction
mikro-orm-neo4j is a native Neo4j driver for
MikroORM. Entities, the EntityManager, repositories, the unit of work and
the identity map all behave the way MikroORM documents them. What changes is what runs underneath: a
filter becomes a Cypher pattern, a many-to-many becomes an edge, and a populate becomes a
traversal.
const books = await em.find(
Book,
{ price: { $gte: 10 }, author: { name: 'Ann' }, tags: { $some: { name: 'Fiction' } } },
{ populate: ['author'], orderBy: { title: 'ASC' }, limit: 20 },
);
MATCH (this0:Book)
WHERE this0.price >= $param0
AND EXISTS { MATCH (a:Author)-[:WROTE]->(this0) WHERE a.name = $param1 }
AND EXISTS { MATCH (this0)-[:TAGGED]->(t:Tag) WHERE t.name = $param2 }
OPTIONAL MATCH (this1:Author)-[:WROTE]->(this0)
RETURN this0 AS node, this1 AS rel_author
ORDER BY this0.title ASC LIMIT 20
What it adds
| Graph-native relationships | Directed, typed edges (IN / OUT), traversal filters at any depth, and populate: ['$infer'] that is genuinely type-safe here. |
| Relationship entities | Edges that carry properties of their own, modelled as ordinary entities on MikroORM's standard pivot path. |
| Query conditions | The whole operator surface mapped onto Cypher — and the ones a graph cannot express refused by name rather than silently ignored. |
| Full-text search | A search field typed as the search backs a real Neo4j fulltext index: analyzers, weights, interface-wide indexes, GraphQL directive. |
| Streaming | em.stream() reads through a Bolt cursor: bounded memory, populated relations merged as rows arrive, close() / asStream() / live stats. |
| Raw Cypher | A cypher tagged template whose interpolations are always named Bolt parameters, em.run<T>(), and virtual entities for read models. |
| Stored routines | APOC, GDS and the built-ins declared once, called like any ORM method, composed into queries, and generated straight from the server. |
| Multi-tenancy | MikroORM's schema option implemented as soft subgraphs, scoping writes, reads and both ends of every relationship. |
| Indexes & constraints | @Index() / @Unique() materialized as real Neo4j indexes and constraints through orm.schema.ensureIndexes(). |
| GraphQL SDL | Export the metadata as SDL for @neo4j/graphql, comments included — schemas an LLM can actually read, and a full CRUD API on NestJS. |
| Observability | One execution funnel, so debug, slowQueryThreshold, per-query labels and a custom logger all work with no driver-specific setup. |
Plus dual-format publishing (ESM and CommonJS) and type augmentation of @mikro-orm/core, so the
graph-specific options autocomplete without a single as any.
Requirements
| Requirement | Version |
|---|---|
| Node.js | ≥ 22 (the driver uses node:fs's globSync) |
@mikro-orm/core | 7.x — pinned exactly, see stored routines |
| Neo4j | ≥ 5; ≥ 5.7 if you use schemas |
What a graph changes
Three things behave differently from the SQL drivers, and they are worth knowing before the first query:
- There are no tables, so there is no DDL. A graph's schema is its indexes and constraints;
orm.schema.ensureIndexes()creates them and is idempotent. There is no migration story to set up, and adding a property to an entity is not a migration. - A "pivot table" is the edge itself. Many-to-many stays on MikroORM's pivot path, so
pivotEntityand relation properties behave as documented — but the join is a pattern match. - Anything the driver cannot honour is refused, loudly. Isolation levels, pessimistic locks, partial indexes, SQL fragments in a filter: each throws with a message naming the alternative, rather than being accepted and quietly doing nothing.
Where to start
- Installation — the packages, the server, and the TypeScript setup.
- Quick start — an ORM instance, two entities and a query.
- Modeling the graph — labels, directions, edge properties, indexes.
- Querying — what each operator compiles to.