Quickstart

By the end of this page you will have a SurrealDB schema written in TypeScript, a migration generated from it, that migration applied to a running database, and app code that reads and writes rows as typed values. It takes about ten minutes.

This is a tutorial: follow every step in order and it works. The guides cover variations once you are comfortable.

Set up with AI

Prefer to let your AI agent set it up? Paste this prompt into Claude Code, Cursor, or any coding agent and it will install Schemic, scaffold your schema, and wire up migrations — asking you about your tables as it goes.

prompt
Set up Schemic in my project to manage my SurrealDB schema as code. Before changing anything, read the reference at https://surrealdb.schemic.dev/llms-full.txt so you use the current API. Then: 1. Install `@schemic/cli`, `@schemic/surrealdb`, and `zod`, and run `npx schemic init` to scaffold the config, the `database/` layout, and a sample schema. 2. Configure the connection in `schemic.config.ts` with `surrealConnection({ url, namespace, database })` read from environment variables. Reuse my app's existing connection or env vars if it already has them; otherwise ask me how I want the connection defined. 3. Verify it works: run `npx schemic doctor` (it tests the connection) and `npx schemic status`, and confirm both succeed before continuing. 4. Ask me whether I already have an existing database. If I do, run `npx schemic pull` to preview the introspected schema, then `npx schemic pull --write` to adopt it into my TypeScript files. If I don't, ask me which tables and fields I need and define them with the `s.*` builder (a superset of Zod, where `$`-prefixed methods like `.$assert` and `.$default` emit SurrealQL DDL). 5. Generate a migration with `npx schemic gen <name>`, then show it to me — let me review it and run `npx schemic migrate` myself when I'm ready. Don't apply it to my database; that's my call. 6. Show me how to read and write rows type-safely with `App<typeof Table>` and `encode` / `decode`. Walk me through each step as you go.

Prefer to do it by hand? The rest of this page is the hands-on walkthrough — about ten minutes from an empty directory to typed, migrated rows.

Prerequisites

Install these first. SurrealQL differs across SurrealDB majors, so the versions matter.

SurrealDB CLI 3.x Node 18+ or Bun A terminal
  • SurrealDB 3.x on your PATH. Check with surreal version.
  • Node 18+ (or Bun). Check with node --version.

Create a project

npm create schemic@latest

create schemic is interactive: it asks for a project directory and a database driver, installs everything, and scaffolds your project. Run it in an empty directory for a new project, or inside an existing one — it merges the @schemic dependencies and a db script into your package.json without touching the rest of your setup. For the SurrealDB driver it scaffolds:

  • schemic.config.ts — where your database connection lives
  • database/schema/tables/user.ts — a sample user table
  • database/seed/index.ts — the seed script
  • database/migrations/meta/_snapshot.json — migration state
  • .env.example — connection environment template

Start a local SurrealDB

In a second terminal, start an in-memory database with root credentials that match the scaffolded config:

Shell
surreal start --user root --pass root memory

Leave it running. The scaffolded schemic.config.ts connects to ws://127.0.0.1:8000/rpc with namespace app and database app, which is exactly what this command serves.

The config reads its credentials from the environment, so copy the generated template to .env (it already has the matching root / root values):

Shell
cp .env.example .env

Read the generated schema

Open database/schema/tables/user.ts. This is the single source of truth; the DDL below is derived from it.

database/schema/tables/user.ts TypeScript
import { s, defineTable, surql } from "@schemic/surrealdb";

export const User = defineTable("user", {
  name: s.string().$assert(surql`string::len($value) > 0`),
  email: s.email().$unique(),
  createdAt: s.datetime().$default(surql`time::now()`).$readonly(),
}).schemafull();

It generates this DDL:

Generated DDL SurrealQL
DEFINE TABLE user TYPE NORMAL SCHEMAFULL;
DEFINE FIELD name ON TABLE user TYPE string ASSERT string::len($value) > 0;
DEFINE FIELD email ON TABLE user TYPE string ASSERT string::is_email($value);
DEFINE INDEX user_email_idx ON TABLE user FIELDS email UNIQUE;
DEFINE FIELD createdAt ON TABLE user TYPE datetime DEFAULT time::now() READONLY;

A few things to notice, each of which you will use constantly:

  • s.email() emits an ASSERT string::is_email($value) constraint, and .$unique() adds a UNIQUE INDEX — both enforced by SurrealDB on write.
  • .$assert(surql) on name adds a custom constraint (here, a non-empty check).
  • s.datetime() is a codec field: a Date in your TypeScript code, a datetime on the wire.
  • .$default(...) sets a database-side DEFAULT — here time::now(), written with the surql tag — and .$readonly() marks the field READONLY.

Generate your first migration

schemic gen diffs your schema against the recorded snapshot and writes a reviewable migration for the difference:

Shell
npx schemic gen initial

The migration is a single SurrealQL file with an up and down branch — idempotent DEFINE … OVERWRITE to apply, REMOVE to roll back. Open it before applying anything:

The generated migration SurrealQL
-- 20260613090000_initial
-- Generated by @schemic/core. Review before applying.

IF $direction = "up" {
    DEFINE TABLE OVERWRITE user TYPE NORMAL SCHEMAFULL;
    DEFINE FIELD OVERWRITE name ON TABLE user TYPE string ASSERT string::len($value) > 0;
    DEFINE FIELD OVERWRITE email ON TABLE user TYPE string ASSERT string::is_email($value);
    DEFINE FIELD OVERWRITE createdAt ON TABLE user TYPE datetime DEFAULT time::now() READONLY;
    DEFINE INDEX OVERWRITE user_email_idx ON TABLE user FIELDS email UNIQUE;
} ELSE {
    REMOVE TABLE IF EXISTS user;
};

You commit these files to version control.

Apply the migration

shell
npx schemic migrate
↑ 20260613090000_initial
 
✓ Applied 1 migration.

Confirm the state:

shell
npx schemic status
✓ applied 20260613090000_initial
 
1 migration, 0 pending.

Your database now has the user table.

Evolve the schema

A schema change is an edit to your TypeScript. Add a post table next to user. Create database/schema/tables/post.ts:

database/schema/tables/post.ts TypeScript
import { s, defineTable, surql } from "@schemic/surrealdb";
import { User } from "./user";

export const Post = defineTable("post", {
  title: s.string().$assert(surql`string::len($value) > 0`),
  body: s.string(),
  author: User.record(),
  publishedAt: s.datetime().optional(),
  createdAt: s.datetime().$default(surql`time::now()`).$readonly(),
}).schemafull();

User.record() is a typed record link — a record<user> field on the wire — built from the imported User definition, so the table name lives only in user.ts. Generate and apply a migration for the change:

Shell
npx schemic gen add_posts
npx schemic migrate

gen writes a second migration containing only the new post table — it diffs against the snapshot, so it never re-defines user.

Read and write rows as typed values

Your schema also gives you codecs and types for app code. encode turns app values into a wire payload for a write; decode turns a database row back into app values.

app.ts TypeScript
import { Surreal } from "surrealdb";
import { User } from "./database/schema/tables/user";
import type { App } from "@schemic/surrealdb";

const db = new Surreal();
await db.connect("ws://127.0.0.1:8000/rpc", {
  namespace: "app",
  database: "app",
  authentication: { username: "root", password: "root" },
});

// encode() builds the wire payload — createdAt has a DB default, so you omit it.
// db.query returns one result set per statement, each an array of wire rows.
const [[created]] = await db.query<[unknown[]]>("CREATE user CONTENT $data", {
  data: User.encode({ name: "Ada Lovelace", email: "ada@example.com" }),
});

// decode() validates the returned row and converts wire values to app values.
const ada: App<typeof User> = User.decode(created);

console.log(ada.createdAt instanceof Date); // true — a Date, not a wire datetime

User.encode(...) is typed to the create shape (fields with a default are optional), and App<typeof User> is the decoded app type. The createdAt you read back is a JavaScript Date, decoded from the datetime SurrealDB stored.

What you built

  • A SurrealDB schema authored entirely in TypeScript.
  • Two migrations, generated by diffing that schema and committed to your repo.
  • App code that writes and reads rows as fully-typed values, with the wire/app conversion handled for you.

Next steps