Getting Started with SurrealDB
Short version: npm i -g layerbase then lbase create surreal1 -e surrealdb --start runs SurrealDB on your machine, and lbase url surreal1 prints the ws:// address the code below connects to. SurrealDB's license keeps it out of Layerbase Cloud, so this is a local and self-hosted engine: use the CLI, or Layerbase Desktop if you would rather click than type.
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 with the Layerbase CLI: SurrealDB's license keeps it out of Layerbase Cloud, so this one is a local and self-hosted engine.
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
- FAQ
- Wrapping Up
Create a SurrealDB Instance
Local with the Layerbase CLI
The Layerbase CLI (formerly SpinDB) gets you a local SurrealDB server with a single command and no Docker dependency. (What is the Layerbase CLI?)
Install the Layerbase CLI globally:
npm i -g layerbase # npm
pnpm add -g layerbase # pnpmOr run it directly without installing:
npx layerbase create surreal1 -e surrealdb --start # npm
pnpx layerbase create surreal1 -e surrealdb --start # pnpmIf you installed globally, create and start a SurrealDB instance:
lbase create surreal1 -e surrealdb --startThe CLI downloads the SurrealDB binary for your platform, configures it, and starts the server. Verify it's running:
lbase url surreal1ws://127.0.0.1:8000Leave the server running. We'll connect to it from TypeScript in the next section.
Hosting SurrealDB
SurrealDB is licensed in a way that stops us offering it as a managed service, so you cannot create one on Layerbase Cloud the way you can with Postgres or FerretDB. Run it locally with the Layerbase CLI, or with Layerbase Desktop if you would rather click than type. For production, self-host it or use SurrealDB's own hosted offering.
Against a remote server the only change to this guide is the connection URL, which uses wss:// instead of ws://:
await db.connect('wss://your-surrealdb-host:8000')Use that server's credentials for db.signin(). Everything else below works the same.
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.
FAQ
Can I host SurrealDB on Layerbase Cloud?
No. Its license stops us offering it as a managed service, so there is no SurrealDB on the create page. Run it locally with the Layerbase CLI or Layerbase Desktop, and self-host it or use SurrealDB's own hosted offering for production.
How does the client connect?
Over a WebSocket. Locally that is the ws:// address from lbase url surreal1, and against a remote server the only change to this guide is wss:// plus that server's credentials for db.signin(). Everything after the connection is identical.
Do I need Docker to run SurrealDB locally?
No. lbase create surreal1 -e surrealdb --start downloads the SurrealDB binary for your platform and runs it as a normal process, then lbase stop surreal1 and lbase start surreal1 control it.
Do graph queries in SurrealQL need JOINs?
No, and that is the point of the traversal syntax in this post. Records link to each other through typed edges, so a multi-hop query walks those edges directly instead of assembling junction tables and nested JOINs the way the relational equivalent would.
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:
lbase stop surreal1 # Stop the server
lbase start surreal1 # Start it again
lbase list # See all your database instancesThe Layerbase CLI 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.
Keep reading
- Fly.io is raising Machine memory prices 20% on October 1Fly emailed customers that Fly Machines memory prices rise 20% on October 1, 2026. CPU stays the same, and Sprites get cheaper. Here is what that does to a Postgres cluster, and how to move the database without moving the app.
- Managed Postgres, Redis and more in Sรฃo Paulo: dedicated servers for BrazilLayerbase dedicated servers can now be placed in South America (Sรฃo Paulo). Three flat-price sizes, every Layerbase Cloud engine on one single-tenant machine, and an honest account of what running in Brasil does and does not give you.
- MariaDB 13.0 and 12.3 LTS on Layerbase: which line to pickMariaDB 13.0 and 12.3 are now offered on Layerbase alongside 10.11, 11.4 and 11.8, and the older lines have been refreshed to their current patches. Here is what actually shipped in 13.0, what 12.3 changed, why one of them is the LTS and the other is not, and what happens to databases already on 11.8 (nothing).
- When Will Postgres 19 Be Released?PostgreSQL 19 still has no announced GA date. Beta 4 shipped on September 24, 2026 and a release candidate is expected in early October. Here is the timeline, what is still in, what got pulled, and how to run the beta today.