Skip to main content

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.

src/entities.ts
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 it

direction: '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:

src/entities.ts (defineEntity)
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​

src/main.ts
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 propertiesModeling the graph
Know what a filter compiles toQuery conditions
Search textFull-text search
Process more rows than fit in memoryStreaming
Write Cypher by handRaw Cypher
Split data per tenantMulti-tenancy
See what the driver is doingObservability