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.
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 3.x on your
PATH. Check withsurreal version. - Node 18+ (or Bun). Check with
node --version.
Create a project
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 livesdatabase/schema/tables/user.ts— a sampleusertabledatabase/seed/index.ts— the seed scriptdatabase/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:
surreal start --user root --pass root memoryLeave 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):
cp .env.example .envRead the generated schema
Open database/schema/tables/user.ts. This is the single source of truth; the DDL below is derived from it.
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:
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 anASSERT string::is_email($value)constraint, and.$unique()adds aUNIQUE INDEX— both enforced by SurrealDB on write..$assert(surql…)onnameadds a custom constraint (here, a non-empty check).s.datetime()is a codec field: aDatein your TypeScript code, adatetimeon the wire..$default(...)sets a database-sideDEFAULT— heretime::now(), written with thesurqltag — and.$readonly()marks the fieldREADONLY.
Generate your first migration
schemic gen diffs your schema against the recorded snapshot and writes a reviewable migration for the difference:
npx schemic gen initialThe 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:
-- 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
Confirm the state:
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:
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:
npx schemic gen add_posts
npx schemic migrategen 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.
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 datetimeUser.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.