A small, complete web application that models movies, people, genres, and user
ratings as a property graph, and serves recommendations that are literally
graph traversals. Built for CognoDB (a Neo4j/Bolt-compatible graph database),
queried in Cypher over the Bolt protocol via the official neo4j-driver.
Stack: CognoDB (Cypher/Bolt) · Node.js + Express · React (Vite)
React (Vite) ──HTTP──> Express API ──Bolt (neo4j-driver)──> CognoDB
│
└── seed script (curated inline dataset)
All Cypher lives in server/src/queries/index.js; routes handle only HTTP
concerns (validation, status codes). The frontend never touches the database.
Three tabs, one per query.
Opening a film returns its whole neighborhood — genres, cast, directors — in a single round trip, alongside similar titles ranked by the similarity query.
Recommendations for the selected user, with the supporting evidence shown inline. Rating a film removes it from the list; undo brings it back.
The shortest path drawn as the graph it is — people in teal, films in amber. Keanu Reeves and Tom Hanks never shared a film, but three films link them.
┌─────────┐
┌───ACTED_IN────▶│ Movie │────IN_GENRE────┐
│ {role} │ movieId │ │
┌──┴────┐ │ title │ ┌────┴───┐
│Person │──DIRECTED▶│ year │ │ Genre │
│ name │ └────▲────┘ │ name │
└───────┘ │ └────────┘
RATED
{score, timestamp}
│
┌────┴────┐
│ User │
│ userId │
└─────────┘
Nodes: Movie, Person, Genre, User
Relationships: (Person)-[:ACTED_IN]->(Movie), (Person)-[:DIRECTED]->(Movie),
(Movie)-[:IN_GENRE]->(Genre), (User)-[:RATED {score, timestamp}]->(Movie)
- Ratings are relationship properties, not nodes. A rating has no identity
of its own and nothing else in the model connects to it — it is purely a
qualified edge between a user and a movie. If ratings grew into reviews
(with comments, likes, replies), they would earn nodehood; until then, a
Ratingnode would add a hop to every recommendation traversal for no modeling benefit. - Genres are nodes, not an array property on Movie. Genres are a shared,
enumerable vocabulary, and "movies that share a genre" is a core traversal
in the similarity query. As nodes, that co-membership is a two-hop path
(m)-[:IN_GENRE]->(g)<-[:IN_GENRE]-(rec); as an array property it would be a full scan with list-intersection logic. - One
Personlabel for actors and directors, two relationship types. The same human can act and direct. Role lives in the relationship type (ACTED_INvsDIRECTED), so "everything this person worked on" is one pattern and queries can widen or narrow with[:ACTED_IN|DIRECTED]. - Uniqueness constraints on every id-like property (
movieId,personId,userId, genrename). Constraints double as indexes, so everyMERGEduring seeding is an index lookup rather than a scan, and the seed script is safely idempotent — running it twice produces the identical graph. - Referential integrity by construction: the ratings seed uses
MATCHon Movie (notMERGE), so a rating for an unknown movie is skipped rather than creating a ghostMovienode holding only an id.
The seed data (19 movies, 47 people, 8 users, ~35 ratings) is intentionally small and hand-curated so the graph's behavior is legible end to end:
- Four genre clusters (animation / sci-fi / crime / drama) give the content-based similarity query real structure to find.
- Users come in pairs with overlapping taste and one deliberate gap each (e.g. users 3 and 4 agree on sci-fi, but user 3 hasn't seen Interstellar), so collaborative filtering has a designed, verifiable "right answer."
- The cast graph is deliberately connected across clusters (Kevin Bacon and Laurence Fishburne bridge otherwise-separate neighborhoods), so shortest-path queries return interesting multi-hop chains.
The seed uses batched UNWIND + MERGE, so the same script scales unchanged
to a full MovieLens-sized dump — small data here is a presentation choice, not
an architectural limit.
Recommendation is a relationship problem: every question the app answers is "what is near this thing, through which connections?" In a property graph those questions are short traversals; in a relational schema each one is a self-join pileup.
Concretely, collaborative filtering here is a two-hop walk: me → movies I
liked → people who agreed → movies they liked that I haven't seen. In SQL
that is the ratings table joined to itself twice, plus an anti-join for the
"haven't seen" filter, with the optimizer working over ever-growing
intermediate result sets. In Cypher it reads exactly like the sentence above,
and the database walks only the neighborhoods it needs.
The six-degrees feature makes the difference categorical rather than
convenient: shortestPath over a variable-length pattern
([:ACTED_IN*..12]) has no reasonable SQL equivalent — path-finding of
unknown depth requires recursive CTEs that are painful to write and expensive
to run, while it is a single native operation in a graph database.
All in server/src/queries/index.js, commented in place.
MATCH (m:Movie {movieId: $movieId})-[:IN_GENRE]->(g:Genre)<-[:IN_GENRE]-(rec:Movie)
WHERE rec <> m
WITH m, rec, count(g) AS genreScore
OPTIONAL MATCH (m)<-[:ACTED_IN|DIRECTED]-(p:Person)-[:ACTED_IN|DIRECTED]->(rec)
WITH rec, genreScore + count(p) * 3 AS score
RETURN rec.movieId AS movieId, rec.title AS title, score
ORDER BY score DESC, rec.title
LIMIT 10Candidate movies are found through shared-genre paths, then boosted by shared
people. The weighting (genre ×1, person ×3) encodes a judgment: a shared
director or star is a stronger similarity signal than a shared genre.
OPTIONAL MATCH keeps genre-only matches in the result set instead of
filtering them out.
vs SQL: SQL would need a movie_genres table joined to itself to find
shared genres, a credits table joined to itself for shared people, and a
third join to combine the two scores. The graph already stores those
connections, so each "shared X" is just a short hop between two movies.
MATCH (me:User {userId: $userId})-[r1:RATED]->(m:Movie)<-[r2:RATED]-(other:User)
WHERE r1.score >= 4 AND r2.score >= 4 AND other <> me
WITH me, other, count(m) AS overlap
ORDER BY overlap DESC
LIMIT 25
MATCH (other)-[r3:RATED]->(rec:Movie)
WHERE r3.score >= 4
AND NOT EXISTS { (me)-[:RATED]->(rec) }
RETURN rec.movieId AS movieId, rec.title AS title,
count(*) AS supporters,
round(avg(r3.score) * 10) / 10 AS avgScore
ORDER BY supporters DESC, avgScore DESC
LIMIT 10Stage 1 finds taste neighbors by counting co-liked movies; stage 2 collects
what those neighbors liked, and the NOT EXISTS pattern predicate excludes
anything the user has already rated. Rating a recommended movie in the UI and
watching it drop off the list is this predicate working live.
vs SQL: this needs the ratings table — usually the biggest table —
joined to itself three times, plus a fourth pass to filter out movies the
user has already seen. SQL scans those large tables to rediscover who is
connected to whom. The graph starts at one user and walks outward, so the
work depends on that user's neighborhood rather than the total number of
ratings in the system.
MATCH (a:Person {name: $from}), (b:Person {name: $to})
MATCH p = shortestPath((a)-[:ACTED_IN*..12]-(b))
RETURN [n IN nodes(p) | coalesce(n.name, n.title)] AS path,
length(p) / 2 AS moviesTwo actors are "connected" when they appear in the same movie. This query finds the shortest such chain between any two actors — actor → shared movie → actor → shared movie → … — capped at 6 movies. The result is the full chain, which the UI draws as connected nodes. Try Keanu Reeves → Tom Hanks: they never appeared together, but a three-movie chain links them through The Matrix, Mystic River, and Apollo 13.
vs SQL: SQL has no built-in way to find a path of unknown length. You
would write a recursive query that adds one hop at a time, manually tracks
which actors it has already visited to avoid loops, and builds up the chain
as it goes — then picks the shortest result. Cypher does it in one line with
shortestPath(). This is the clearest case for using a graph database here.
- Node.js 18+
- A running CognoDB instance (Neo4j/Bolt-compatible) and its Bolt URI, username, and password
git clone <repo-url> movie-graph
cd movie-graph
cd server && npm install
cd ../client && npm installcd server
cp .env.example .envEdit server/.env:
DB_URI=bolt://localhost:7687 # your CognoDB Bolt endpoint
DB_USER=neo4j # your credentials
DB_PASS=your_password_here
PORT=3000
If the connection fails on startup, the usual suspects in order: try the
neo4j:// scheme instead of bolt:// (or vice versa); use bolt+s:// /
neo4j+s:// for TLS-only hosted instances; and if the instance runs without
auth, credentials can be left as placeholders.
cd server
npm run schema # 4 uniqueness constraints (also create the backing indexes)
npm run seed # loads the curated dataset — idempotent, safe to re-runSanity check in the CognoDB query console:
MATCH (m:Movie) RETURN count(m); // 19
MATCH (p:Person) RETURN count(p); // 47
MATCH ()-[r:RATED]->() RETURN count(r); // ~35# terminal 1 — API on :3000
cd server && npm run dev
# terminal 2 — frontend on :5173
cd client && npm run devOpen http://localhost:5173. API health check: http://localhost:3000/health
should return {"api":"up","db":"up"}.
- Movies — search "matrix", open it, browse similar movies (note the score column and the weighting explained under the list).
- For you — select User 3 (sci-fi taste): Interstellar is the top
recommendation. Click Rate ★5 — it disappears from the list, because
the
NOT EXISTSfilter now sees it as watched. Undo rating brings it back. Fully repeatable. - Actor connections — Keanu Reeves → Tom Hanks, Find path: a three-movie chain rendered as the graph path it is.
The seed is idempotent, so npm run seed restores all original ratings. For a
guaranteed clean slate:
MATCH (n) DETACH DELETE n;then re-run npm run schema (no-op if constraints exist) and npm run seed.
| Method | Endpoint | Purpose |
|---|---|---|
| GET | /health |
API + DB liveness |
| GET | /movies?search= |
Title search |
| GET | /movies/:id |
Detail: genres, cast, avg rating |
| GET | /movies/:id/similar |
Content-based similar movies |
| GET | /users |
Users (for the demo dropdown) |
| GET | /users/:id/recommendations |
Collaborative-filtering recs |
| POST | /users/:id/ratings |
Rate a movie {movieId, score} |
| DELETE | /users/:id/ratings/:movieId |
Remove a rating (deletes the edge) |
| GET | /actors |
Actors (for the path dropdowns) |
| GET | /actors/path?from=&to= |
Shortest connection between actors |
movie-graph/
├── server/
│ ├── src/
│ │ ├── db.js # Bolt driver singleton, read/write helpers
│ │ ├── schema.js # uniqueness constraints (npm run schema)
│ │ ├── queries/index.js # ALL Cypher, commented — start reading here
│ │ ├── routes/ # movies, users, actors — HTTP concerns only
│ │ └── index.js # Express app + health check
│ └── seed/seed.js # curated dataset, batched UNWIND + MERGE
└── client/ # Vite + React: Movies / For you / Actor connections


