# Getting Started with SurrealDB

> Build a social movie recommendation graph with SurrealDB and TypeScript, using one query language for documents, relations, and graph traversals without JOINs.

Published: 2026-03-07
Canonical: https://layerbase.com/blog/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](https://layerbase.com/desktop/download) 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](#create-a-surrealdb-instance)
  - [Local with the Layerbase CLI](#local-with-the-layerbase-cli)
  - [Hosting SurrealDB](#hosting-surrealdb)
- [Set Up the Project](#set-up-the-project)
- [The Movie Dataset](#the-movie-dataset)
- [Create Records](#create-records)
- [Create Relationships](#create-relationships)
- [Graph Queries](#graph-queries)
- [The PostgreSQL Equivalent](#the-postgresql-equivalent)
- [When to Reach for SurrealDB](#when-to-reach-for-surrealdb)
- [FAQ](#faq)
- [Wrapping Up](#wrapping-up)

## Create a SurrealDB Instance

### Local with the Layerbase CLI

The [Layerbase CLI](https://layerbase.com/docs/cli) (formerly SpinDB) gets you a local SurrealDB server with a single command and no Docker dependency. ([What is the Layerbase CLI?](https://layerbase.com/docs/cli))

Install the Layerbase CLI globally:

```bash
npm i -g layerbase    # npm
pnpm add -g layerbase # pnpm
```

Or run it directly without installing:

```bash
npx layerbase create surreal1 -e surrealdb --start  # npm
pnpx layerbase create surreal1 -e surrealdb --start # pnpm
```

If you installed globally, create and start a SurrealDB instance:

```bash
lbase create surreal1 -e surrealdb --start
```

The CLI downloads the SurrealDB binary for your platform, configures it, and starts the server. Verify it's running:

```bash
lbase url surreal1
```

```text
ws://127.0.0.1:8000
```

Leave 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](https://cloud.layerbase.com) the way you can with Postgres or FerretDB. Run it locally with the Layerbase CLI, or with [Layerbase Desktop](https://layerbase.com/desktop/download) 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://`:

```typescript
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

```bash
mkdir surrealdb-movie-graph && cd surrealdb-movie-graph
pnpm init
pnpm add surrealdb
pnpm add -D tsx typescript
```

Create a file called `graph.ts`. All the code in this post goes into that one file.

Start with the connection boilerplate:

```typescript
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:

```typescript
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.

```typescript
// 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:

```typescript
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?

```typescript
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:

```typescript
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:

```typescript
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:

1. **$alice_movies**: Collect all movies Alice has rated
2. **$similar_users**: Find users who gave 4+ to those same movies
3. **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:

```sql
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:

```sql
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](https://surrealdb.com/docs) covers schema enforcement, events, live queries, changefeeds, and embedded functions.

To manage your local SurrealDB instance:

```bash
lbase stop surreal1    # Stop the server
lbase start surreal1   # Start it again
lbase list             # See all your database instances
```

The [Layerbase CLI](https://layerbase.com/docs/cli) manages 20+ engines, so you can keep SurrealDB running alongside Redis, InfluxDB, or whatever else your stack needs. Prefer a GUI? [Layerbase Desktop](https://layerbase.com/desktop/download) has you covered on macOS.
