Getting Started with SurrealDB
Most applications need more than one data model. User profiles are documents. Friendships are graph edges. Ratings and inventory are relational. Traditionally that means running PostgreSQL, MongoDB, and Neo4j side by side. Three databases, three query languages, three sets of connection logic. It works, but it's a lot of moving parts.
SurrealDB combines all three into one engine with a single query language called SurrealQL. It looks like SQL, but it handles record links, graph traversals, and schemaless documents natively. Create a user as a document, relate them to a movie with a typed edge, traverse that graph in one query. No JOINs, no junction tables, no context-switching.
We'll build a social movie recommendation graph in one TypeScript file. Users rate movies, and we use SurrealDB's graph queries to find recommendations by traversing those ratings. Follow along on your own machine, or spin up a managed instance on Layerbase Cloud.
Contents
- Create a SurrealDB Instance
- Set Up the Project
- The Movie Dataset
- Create Records
- Create Relationships
- Graph Queries
- The PostgreSQL Equivalent
- When to Reach for SurrealDB
- Wrapping Up
Create a SurrealDB Instance
Local with SpinDB
SpinDB gets you a local SurrealDB server with a single command and no Docker dependency. (What is SpinDB?)
Install SpinDB globally:
npm i -g spindb # npm
pnpm add -g spindb # pnpmOr run it directly without installing:
npx spindb create surreal1 -e surrealdb --start # npm
pnpx spindb create surreal1 -e surrealdb --start # pnpmIf you installed globally, create and start a SurrealDB instance:
spindb create surreal1 -e surrealdb --startSpinDB downloads the SurrealDB binary for your platform, configures it, and starts the server. Verify it's running:
spindb url surreal1ws://127.0.0.1:8000Leave the server running. We'll connect to it from TypeScript in the next section.
Layerbase Cloud
If you want a hosted instance instead, Layerbase Cloud has you covered. Create one and pull the connection details from the Quick Connect panel in the dashboard.
Cloud instances use TLS, so the connection URL uses wss:// instead of ws://:
await db.connect('wss://cloud.layerbase.dev:11010')Use the credentials from your Quick Connect panel for db.signin(). Everything else in this guide works identically whether you're running locally or on Layerbase Cloud.
Set Up the Project
mkdir surrealdb-movie-graph && cd surrealdb-movie-graph
pnpm init
pnpm add surrealdb
pnpm add -D tsx typescriptCreate a file called graph.ts. All the code in this post goes into that one file.
Start with the connection boilerplate:
import { Surreal } from 'surrealdb'
const db = new Surreal()
await db.connect('ws://localhost:8000')
await db.signin({ username: 'root', password: 'root' })
await db.use({ namespace: 'test', database: 'test' })
console.log('Connected to SurrealDB')SurrealDB organizes data into namespaces and databases, which act like schemas within a single server. For this tutorial, test/test keeps things simple.
The Movie Dataset
Here are the users and movies we'll work with. In production this would come from your API or an import. We'll turn these into SurrealDB records next:
const users = [
{ id: 'alice', name: 'Alice', favorite_genres: ['sci-fi', 'thriller'] },
{ id: 'bob', name: 'Bob', favorite_genres: ['drama', 'comedy'] },
{ id: 'carol', name: 'Carol', favorite_genres: ['sci-fi', 'drama'] },
{ id: 'dave', name: 'Dave', favorite_genres: ['horror', 'thriller'] },
{ id: 'eve', name: 'Eve', favorite_genres: ['animation', 'fantasy'] },
{ id: 'frank', name: 'Frank', favorite_genres: ['sci-fi', 'action'] },
]
const movies = [
{ id: 'inception', title: 'Inception', year: 2010, genres: ['sci-fi', 'thriller'] },
{ id: 'interstellar', title: 'Interstellar', year: 2014, genres: ['sci-fi', 'drama'] },
{ id: 'the_matrix', title: 'The Matrix', year: 1999, genres: ['sci-fi', 'action'] },
{ id: 'parasite', title: 'Parasite', year: 2019, genres: ['thriller', 'drama'] },
{ id: 'the_intouchables', title: 'The Intouchables', year: 2011, genres: ['comedy', 'drama'] },
{ id: 'spirited_away', title: 'Spirited Away', year: 2001, genres: ['animation', 'fantasy'] },
{ id: 'alien', title: 'Alien', year: 1979, genres: ['sci-fi', 'horror'] },
{ id: 'the_shawshank_redemption', title: 'The Shawshank Redemption', year: 1994, genres: ['drama'] },
{ id: 'mad_max_fury_road', title: 'Mad Max: Fury Road', year: 2015, genres: ['action', 'sci-fi'] },
{ id: 'coco', title: 'Coco', year: 2017, genres: ['animation', 'fantasy'] },
]
const ratings = [
{ user: 'alice', movie: 'inception', score: 5, review: 'Mind-blowing layers of reality' },
{ user: 'alice', movie: 'interstellar', score: 4, review: 'Beautiful but slow in the middle' },
{ user: 'alice', movie: 'the_matrix', score: 5, review: 'Changed how I think about reality' },
{ user: 'alice', movie: 'parasite', score: 4 },
{ user: 'bob', movie: 'the_intouchables', score: 5, review: 'Heartwarming and funny' },
{ user: 'bob', movie: 'parasite', score: 5, review: 'Perfect tension throughout' },
{ user: 'bob', movie: 'the_shawshank_redemption', score: 5 },
{ user: 'carol', movie: 'inception', score: 5, review: 'Nolan at his best' },
{ user: 'carol', movie: 'interstellar', score: 5, review: 'Cried three times' },
{ user: 'carol', movie: 'the_shawshank_redemption', score: 4 },
{ user: 'carol', movie: 'parasite', score: 5 },
{ user: 'dave', movie: 'alien', score: 5, review: 'Perfect horror pacing' },
{ user: 'dave', movie: 'parasite', score: 4, review: 'More thriller than horror, still great' },
{ user: 'dave', movie: 'inception', score: 3 },
{ user: 'eve', movie: 'spirited_away', score: 5, review: 'Watched it ten times' },
{ user: 'eve', movie: 'coco', score: 5, review: 'Never fails to make me cry' },
{ user: 'eve', movie: 'the_intouchables', score: 4 },
{ user: 'frank', movie: 'the_matrix', score: 5, review: 'Peak sci-fi action' },
{ user: 'frank', movie: 'mad_max_fury_road', score: 5, review: 'Non-stop adrenaline' },
{ user: 'frank', movie: 'inception', score: 4 },
{ user: 'frank', movie: 'alien', score: 4 },
]Notice the ratings array contains the relationships between users and movies. In a relational database, this would be a junction table. In SurrealDB, these become graph edges.
Create Records
SurrealDB uses record IDs in the format table:id. CREATE user:alice creates a record in the user table with the ID alice. No schema definition needed upfront.
// Clean up from previous runs
await db.query('REMOVE TABLE user')
await db.query('REMOVE TABLE movie')
await db.query('REMOVE TABLE rated')
// Create users
for (const user of users) {
await db.query(
`CREATE user:${user.id} SET name = $name, favorite_genres = $genres`,
{ name: user.name, genres: user.favorite_genres },
)
}
console.log(`Created ${users.length} users`)
// Create movies
for (const movie of movies) {
await db.query(
`CREATE movie:${movie.id} SET title = $title, year = $year, genres = $genres`,
{ title: movie.title, year: movie.year, genres: movie.genres },
)
}
console.log(`Created ${movies.length} movies`)Each record is a flexible document. Users have favorite_genres as an array, movies have genres, title, and year. Not a single CREATE TABLE or ALTER TABLE in sight. SurrealDB infers the structure from the data.
Create Relationships
This is where SurrealDB gets interesting. The RELATE statement creates a typed, directional edge between two records:
for (const rating of ratings) {
const reviewClause = rating.review ? `, review = $review` : ''
await db.query(
`RELATE user:${rating.user}->rated->movie:${rating.movie} SET score = $score${reviewClause}`,
{ score: rating.score, review: rating.review },
)
}
console.log(`Created ${ratings.length} ratings`)The syntax user:alice->rated->movie:inception creates a record in the rated table that connects Alice to Inception. The arrow notation (->) indicates direction. The edge itself can carry data: score and review are stored directly on the relationship.
This is fundamentally different from a junction table. In PostgreSQL, you'd create a ratings table with user_id, movie_id, score, and review columns. In SurrealDB, the relationship is the record, and you can query it from either direction.
Graph Queries
Here's where the multi-model design pays off. Instead of JOINs across three tables, you traverse the graph with arrow notation.
What did Alice rate?
const aliceMovies = await db.query(
`SELECT ->rated->movie AS movies FROM user:alice FETCH movies`,
)
console.log('\nAlice rated:')
console.log(JSON.stringify(aliceMovies, null, 2))One line. No JOINs. The ->rated->movie path follows Alice's outgoing rated edges to whatever movie records they point to. FETCH resolves the full movie records instead of returning just the IDs.
Who rated the same movies Alice liked?
Now let's find users who also highly rated the movies Alice gave a 4 or 5:
const likeMinds = await db.query(`
SELECT
<-rated<-user.name AS fellow_fan,
title
FROM movie
WHERE <-(rated WHERE score >= 4 AND in = user:alice)
AND <-(rated WHERE score >= 4 AND in != user:alice)
`)
console.log('\nUsers who also loved movies Alice loved:')
console.log(JSON.stringify(likeMinds, null, 2))The <-rated<-user path traverses the graph backwards: starting from movies, following incoming rated edges back to users. This finds every user who highly rated the same movies Alice highly rated.
Recommend movies for Alice
This is the query that makes graph databases click. Find movies Alice hasn't rated but that users with similar taste loved:
const recommendations = await db.query(`
LET $alice_movies = (SELECT VALUE ->rated->movie FROM user:alice);
LET $similar_users = (
SELECT VALUE <-rated<-user FROM movie
WHERE id IN $alice_movies
AND <-rated<-(user WHERE id != user:alice AND score >= 4)
);
SELECT
out.title AS title,
out.year AS year,
out.genres AS genres,
count() AS fans,
math::mean(score) AS avg_score
FROM rated
WHERE in IN array::distinct(array::flatten($similar_users))
AND out NOT IN $alice_movies
AND score >= 4
GROUP BY out
ORDER BY fans DESC, avg_score DESC
LIMIT 5;
`)
console.log('\nRecommended movies for Alice:')
console.log(JSON.stringify(recommendations, null, 2))Here's what each step does:
- $alice_movies: Collect all movies Alice has rated
- $similar_users: Find users who gave 4+ to those same movies
- Final query: See what those similar users also rated highly, exclude movies Alice already knows, rank by popularity and average score
Collaborative filtering in three statements. The graph traversal handles the heavy lifting. No subqueries, no self-joins, no temporary tables.
The PostgreSQL Equivalent
To appreciate what SurrealDB saves you, here's the same recommendation query in PostgreSQL. You'd need three tables first:
CREATE TABLE users (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
favorite_genres TEXT[]
);
CREATE TABLE movies (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
year INTEGER,
genres TEXT[]
);
CREATE TABLE ratings (
user_id TEXT REFERENCES users(id),
movie_id TEXT REFERENCES movies(id),
score INTEGER,
review TEXT,
PRIMARY KEY (user_id, movie_id)
);And then the recommendation query:
WITH alice_movies AS (
SELECT movie_id FROM ratings WHERE user_id = 'alice'
),
similar_users AS (
SELECT DISTINCT r.user_id
FROM ratings r
JOIN alice_movies am ON r.movie_id = am.movie_id
WHERE r.user_id != 'alice'
AND r.score >= 4
),
recommendations AS (
SELECT
r.movie_id,
m.title,
m.year,
m.genres,
COUNT(*) AS fans,
AVG(r.score) AS avg_score
FROM ratings r
JOIN similar_users su ON r.user_id = su.user_id
JOIN movies m ON r.movie_id = m.id
WHERE r.movie_id NOT IN (SELECT movie_id FROM alice_movies)
AND r.score >= 4
GROUP BY r.movie_id, m.title, m.year, m.genres
)
SELECT title, year, genres, fans, ROUND(avg_score, 1) AS avg_score
FROM recommendations
ORDER BY fans DESC, avg_score DESC
LIMIT 5;The PostgreSQL version needs three CTEs, three JOINs, a subquery, and a junction table. It works fine. But the SurrealDB version expresses the same logic as a graph traversal, which maps more naturally to how you actually think about the problem: follow the connections from Alice to her movies, then to other users, then to their movies.
For simple one-hop queries, the gap is even wider. "What movies did Alice rate?" is one line of SurrealQL versus a JOIN across two tables in SQL.
When to Reach for SurrealDB
SurrealDB shines when your data has rich relationships:
- Social features: followers, friends, mutual connections, recommendation engines. Relationships are the core data model, not an afterthought
- Content management: articles linked to authors, tags, categories, and related content, all queryable by traversing edges
- Knowledge graphs: entities connected by typed relationships (person WORKS_AT company, company LOCATED_IN city) with multi-hop queries
- Mixed data models: when you need documents, relations, and graph traversals without running three separate databases
- Rapid prototyping: no migrations, human-readable record IDs, and a query language familiar to anyone who knows SQL
If you find yourself wiring together separate systems for documents, relations, and graph traversals, SurrealDB collapses that into one engine.
Wrapping Up
The full script is under 100 lines of meaningful code. You created users and movies as documents, linked them with typed graph edges, and ran multi-hop traversals that would need complex JOINs in a relational database. The same pattern scales from a toy movie dataset to production social graphs with millions of relationships.
The SurrealDB documentation covers schema enforcement, events, live queries, changefeeds, and embedded functions.
To manage your local SurrealDB instance:
spindb stop surreal1 # Stop the server
spindb start surreal1 # Start it again
spindb list # See all your database instancesSpinDB manages 20+ engines, so you can keep SurrealDB running alongside Redis, InfluxDB, or whatever else your stack needs. Prefer a GUI? Layerbase Desktop has you covered on macOS.