Quick start
Everything below assumes a Neo4j 5 server on bolt://localhost:7687
(installation has a one-line Docker command).
1. Define two entities
An entity is a node; a relation property is an edge with a type and a direction. Nothing else about MikroORM's model changes.
import { Collection } from 'mikro-orm-neo4j';
import { Entity, ManyToOne, OneToMany, PrimaryKey, Property } from '@mikro-orm/decorators/legacy';
@Entity()
export class Author {
@PrimaryKey()
id!: string;
@Property()
name!: string;
@OneToMany(() => Book, (book) => book.author)
books = new Collection<Book>(this);
}
@Entity()
export class Book {
@PrimaryKey()
id!: string;
@Property()
title!: string;
@Property()
price!: number;
// (author)-[:WROTE]->(book)
@ManyToOne(() => Author, { relationship: { type: 'WROTE', direction: 'IN' } })
author!: Author;
}
direction is read from the entity that declares itdirection: 'IN' on Book.author means the edge points into the book — (:Author)-[:WROTE]->(:Book).
Omit relationship entirely and the driver derives a type from the property name.
The same model, written functionally — no decorators, no reflect-metadata, and the graph options
are typed without any extra setup:
import { defineEntity, neo4j } from 'mikro-orm-neo4j';
import crypto from 'node:crypto';
export const AuthorSchema = defineEntity({
name: 'Author',
labels: ['Author', 'Person'],
properties: (p) => ({
id: p.uuid().primary().onCreate(() => crypto.randomUUID()),
name: p.string(),
}),
});
export const BookSchema = defineEntity({
name: 'Book',
properties: (p) => ({
id: p.uuid().primary().onCreate(() => crypto.randomUUID()),
title: p.string(),
price: p.float(),
author: () => neo4j(p.manyToOne(AuthorSchema), { type: 'WROTE', direction: 'IN' }),
}),
});
2. Initialize the ORM
import { MikroORM } from 'mikro-orm-neo4j';
import { Author, Book } from './entities.js';
const orm = await MikroORM.init({
clientUrl: 'bolt://localhost:7687',
user: 'neo4j',
password: 'password',
dbName: 'neo4j',
entities: [Author, Book],
});
// A graph has no tables — its schema is its indexes and constraints.
// Idempotent, so this is safe on every boot.
await orm.schema.ensureIndexes();
3. Write
const em = orm.em.fork();
const author = em.create(Author, { id: 'A1', name: 'Ann' });
em.create(Book, { id: 'B1', title: 'Graph Databases', price: 42, author });
await em.flush();
MERGE (n:Author { id: $id }) SET n.name = $name
MERGE (n:Book { id: $id }) SET n.title = $title, n.price = $price, n.author = $author
MATCH (a:Author) MATCH (b:Book) WHERE a.id = $sId AND b.id = $tId
MERGE (a)-[r:WROTE]->(b)
Writes MERGE on the primary key rather than CREATE, so re-persisting the same key updates the
node instead of duplicating it — see the appendix.
4. Read
const books = await em.find(
Book,
{ price: { $gte: 10 }, author: { name: 'Ann' } },
{ populate: ['author'], orderBy: { title: 'ASC' }, limit: 20 },
);
books[0].author.name; // 'Ann'
{ author: { name: 'Ann' } } traverses the edge — it becomes an EXISTS pattern subquery, not
a property comparison. Passing a key instead ({ author: 'A1' }) compares the denormalized foreign
key stored on the node, because the answer is already there and no traversal is needed.
5. Where to go next
| You want to… | Read |
|---|---|
| Model labels, directions and edge properties | Modeling the graph |
| Know what a filter compiles to | Query conditions |
| Search text | Full-text search |
| Process more rows than fit in memory | Streaming |
| Write Cypher by hand | Raw Cypher |
| Split data per tenant | Multi-tenancy |
| See what the driver is doing | Observability |