Skip to content

Migrate from Supabase

vaif migrate --from supabase reads your Supabase project folder and recreates its tables, storage buckets and function stubs in a VAIF Studio project. Data, users, access rules and function code are separate steps, covered below.

Supabase VAIF Studio Moved by vaif migrate
Tables (supabase/migrations/*.sql) Tables in your project’s database Yes: tables, columns, primary keys, unique and foreign keys
Storage buckets ([storage.buckets.*] in config.toml) Storage buckets Yes: the bucket and whether it is public. Not the files
Edge Functions (supabase/functions/*) Functions A stub for each function. You port the code
Access policies written in SQL Row rules No. Listed in the plan; you recreate them as row rules
Auth users Your project’s users No. See Users
Email and password, magic links The same Turn them on in the console
Social sign-in Social sign-in for your app’s users No. Create your own OAuth apps and set them up for the project
Realtime row changes Row-change subscriptions Code change
@supabase/supabase-js @vaifstudio/client Code change

Install the vaif CLI, then sign in:

Terminal window
npm install -g @vaifstudio/cli
Terminal window
vaif login

Run this in your Supabase project’s root, the folder that contains supabase/:

Terminal window
vaif migrate --from supabase --dry-run

The CLI reads supabase/migrations/*.sql, supabase/config.toml and supabase/functions/, prints what it found, and writes the plan to vaif-migration-plan.md: every table and column with its type and constraints, the access policies it found, buckets, functions, sign-in providers and warnings. Nothing is created.

If your schema is not in migration files, export it into one first:

Terminal window
supabase db dump --schema public > supabase/migrations/schema.sql

To read a folder other than the current one, pass --output-dir <path>.

Terminal window
vaif migrate --from supabase --project-id <your-vaif-project-id>

The project ID can also come from vaif.config.json. The CLI creates the tables in one schema change, then each bucket, then a stub for each function. Hyphens in function names become underscores, because function names use letters, digits and underscores only. It reports each step and writes the plan file again.

Supabase policies are SQL expressions such as auth.uid() = user_id. A VAIF row rule is simpler: a column must equal a claim in the user’s access token. The common policy “users see their own rows” becomes a rule with column user_id and claim sub. See Row rules. A policy that cannot be written that way belongs in a function that checks the caller.

For a table in a new SQL file, put the rule above CREATE TABLE:

-- vaif:owner user_id
CREATE TABLE notes (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL,
body text,
updated_at timestamptz DEFAULT now()
);

For a table that is already migrated, create the rule through the API with a console access token:

Terminal window
curl -X POST https://api.vaif.studio/rls/policies/$PROJECT_ID \
-H "Authorization: Bearer $VAIF_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"tableName":"notes","name":"Own notes","operation":"ALL","field":"user_id","matchClaim":"sub"}'

Use -- vaif:public-read only when visitors may read every row; it cannot filter, for example, to published rows only. Joins, team membership and admin checks need a function that verifies the user’s token before it uses a secret key. Keep a row rule on those tables too, so they cannot be read directly.

Before you push SQL files, remove Postgres policies, views, SQL functions and triggers. Replace serial ids with uuid PRIMARY KEY DEFAULT gen_random_uuid(), enums with text checked in your code, and composite primary keys with one uuid id plus a unique index on the original columns. Use timestamptz DEFAULT now() for timestamps, and set updated_at in your code or in a function.

Export each table from Supabase, for example with supabase db dump --data-only or a script that uses supabase-js. Then insert the rows from a server with createMany() and a secret key:

import { readFileSync } from "node:fs";
import { createVaifClient } from "@vaifstudio/client";
const vaif = createVaifClient({
baseUrl: "https://api.vaif.studio",
projectId: process.env.VAIF_PROJECT_ID,
apiKey: process.env.VAIF_SECRET_KEY, // vk_srv_: server only
});
const rows: Record<string, unknown>[] = JSON.parse(readFileSync("posts.json", "utf8"));
for (let i = 0; i < rows.length; i += 500) {
const { count } = await vaif.from("posts").createMany(rows.slice(i, i + 500));
console.log(`inserted ${i + count} of ${rows.length}`);
}

One createMany() call takes up to 1000 rows, and every row in a call must have the same columns. Load parent tables before the tables whose foreign keys point at them. Copy storage files with upload().

Supabase password hashes cannot be imported. To move your users:

  1. Create each user with POST /projects/{projectId}/users and a body such as { "email": "...", "metadata": { ... } }, with no password. The call takes a console access token of a project developer or higher.
  2. Ask each user to set a new password: your app calls vaif.auth.requestPasswordReset({ email }), or the user signs in with a magic link (vaif.auth.magic.send(email)).

Keep the Supabase user id in metadata if other tables point at it, and update those references.

supabase-js @vaifstudio/client
createClient(url, anonKey) createVaifClient({ baseUrl: "https://api.vaif.studio", projectId, apiKey: "vk_pub_..." })
.from("t").select("a, b") .from("t").select("a", "b").list()
.order("created_at", { ascending: false }) .orderBy("created_at", "desc")
.insert(row).select() .create(row)
.update(values).eq("id", id) .update(id, values)
.delete().eq("id", id) .delete(id)
.single() .single() (returns { data, error })
auth.signUp({ email, password }) auth.signUp(email, password)
auth.signInWithPassword({ email, password }) auth.login(email, password)
auth.signInWithOtp({ email }) auth.magic.send(email)
A channel listening for row changes on a table from(table).on("*", fn).subscribe()
functions.invoke(name, { body }) functions.invoke(name, { body })
storage.from(bucket).upload(path, file) upload(vaif, bucket, path, file) from @vaifstudio/client/storage

A sample after the switch:

import { createVaifClient } from "@vaifstudio/client";
const vaif = createVaifClient({
baseUrl: "https://api.vaif.studio",
projectId: process.env.VAIF_PROJECT_ID,
apiKey: process.env.VAIF_PUBLISHABLE_KEY,
});
type Post = { id: string; title: string; author_id: string; created_at: string };
const { data: latest } = await vaif
.from<Post>("posts")
.select("id", "title", "created_at")
.orderBy("created_at", "desc")
.limit(10)
.list();
vaif
.from<Post>("posts")
.on("INSERT", (event) => console.log("new post", event.new?.title))
.subscribe();
console.log(latest);

Supabase Edge Functions run on Deno. VAIF functions run on Node.js by default. The stubs the CLI creates have the same names, with hyphens replaced by underscores. Port each function’s code, and replace Deno-only imports with Node.js built-ins or fetch. See Creating functions.

  • Run vaif migrate --from supabase --dry-run and read vaif-migration-plan.md
  • Run the migration and check the tables in the console
  • Recreate access policies as row rules
  • Copy data, parents first, and copy storage files
  • Create users and send password resets
  • Port functions and switch the app to @vaifstudio/client