Guides
Migrating from Supabase
Berth follows many of the same ideas as Supabase: Postgres per project, PostgREST style filters, a publishable key plus user JWT, row level security. There is no client SDK, so each supabase-js call becomes one fetch. This page maps them, and lists what Berth does not do.
Client setup
| Supabase | Berth |
|---|---|
createClient(url, anonKey) | base URL https://api.atberth.com/v1/apps/{app} plus the publishable key (bpk_...) in the apikey header |
| service role key | secret key (bsk_...), server only |
| user access token | user JWT in Authorization: Bearer |
| project | app, one Postgres database and role each |
A ten line helper covers most of what the client did:
const BASE = "https://api.atberth.com/v1/apps/notes_app";
const KEY = "bpk_...";
let session = null; // { access_token, refresh_token, user }
async function berth(path, { method = "GET", body, query } = {}) {
const url = new URL(BASE + path);
if (query) for (const [k, v] of Object.entries(query)) url.searchParams.append(k, v);
const headers = { apikey: KEY, "Content-Type": "application/json" };
if (session) headers.Authorization = `Bearer ${session.access_token}`;
const res = await fetch(url, { method, headers, body: body && JSON.stringify(body) });
const data = res.status === 204 ? null : await res.json();
if (!res.ok) throw Object.assign(new Error(data.message), { code: data.error });
return data;
}Table queries
| supabase-js | Berth |
|---|---|
from("notes").select("id,title") | GET /tables/notes/rows?select=id,title |
.eq("done", false) | done=eq.false |
.neq, .gt, .gte, .lt, .lte | neq., gt., gte., lt., lte. |
.like, .ilike("title", "%milk%") | title=ilike.*milk* |
.in("slug", ["a","b"]) | slug=in.(a,b) |
.is("x", null) | x=is.null |
.contains("tags", ["home"]) | tags=cs.["home"] |
.not("done", "eq", true) | done=not.eq.true |
.order("created_at", { ascending: false }) | order=created_at.desc |
.range(20, 29) | offset=20&limit=10, or better, cursor |
.select("*", { count: "exact" }) | count=exact |
.single() by id | GET /tables/notes/rows/{id} |
.insert(obj or array) | POST /tables/notes/rows |
.upsert(obj, { onConflict: "slug" }) | POST /rows?upsert=true&on_conflict=slug |
.update(obj).eq("id", id) | PATCH /rows/{id}, or PATCH /rows?filters |
.delete().eq("done", true) | DELETE /rows?done=eq.true |
.rpc("fn", args) | POST /rpc/fn or berth rpc, with the secret key (see SQL). From a phone, wrap it in a function |
Side by side:
// Supabase
const { data } = await supabase.from("notes")
.select("id,title").eq("done", false).order("created_at", { ascending: false }).range(0, 9);
// Berth
const { rows } = await berth("/tables/notes/rows", {
query: { select: "id,title", done: "eq.false", order: "created_at.desc", limit: 10 },
});The same query from a shell:
berth tables create --app "$APP" notes title:text:notnull done:boolean
curl -sS -X POST https://api.atberth.com/v1/apps/$APP/tables/notes/rows \
-H "Authorization: Bearer $SECRET_KEY" \
-H "Content-Type: application/json" \
-d '[{"title":"Buy milk","done":false},{"title":"Call the bank","done":true}]' > /dev/null
curl -sS -G https://api.atberth.com/v1/apps/$APP/tables/notes/rows \
-H "Authorization: Bearer $SECRET_KEY" \
--data-urlencode "select=id,title" \
--data-urlencode "done=eq.false" \
--data-urlencode "order=created_at.desc" \
--data-urlencode "limit=10"Auth
| supabase-js | Berth |
|---|---|
auth.signUp({ email, password }) | POST /auth/signup |
auth.signInWithPassword | POST /auth/login |
auth.signInWithOtp({ email }) | POST /auth/code, then POST /auth/verify with the 6 digit code |
auth.getUser() | GET /auth/me |
auth.updateUser | PATCH /auth/me |
auth.refreshSession() | POST /auth/refresh (rotating, store the new token) |
auth.signOut() | POST /auth/logout |
auth.admin.* | /auth/users with the secret key |
// Supabase
await supabase.auth.signInWithPassword({ email, password });
// Berth
session = await berth("/auth/login", { method: "POST", body: { email, password } });curl -sS -X POST https://api.atberth.com/v1/apps/$APP/auth/signup \
-H "apikey: $PUBLISHABLE_KEY" \
-H "Content-Type: application/json" \
-d '{"email":"ada@example.com","password":"correct horse battery"}' | jq .user.emailUsers do not move over with their Supabase passwords. Create them with POST /auth/users and let them sign in with an email code, then set a password.
Row level security
The usual Supabase policy using (auth.uid() = user_id) is the Berth owner policy. Berth adds owner_id for you and turns on RLS. Rename your user_id column to owner_id, or copy it across, when you move data.
berth tables policy --app "$APP" notes --read owner --write ownerThe API manages four policy levels. For rules beyond them (roles, team membership), put the logic in a function that uses the secret key.
Storage
| supabase-js | Berth |
|---|---|
storage.from("avatars").upload(path, file) | PUT /storage/objects/avatars/{path} with the raw body |
.download(path) | GET /storage/objects/avatars/{path} |
.getPublicUrl(path) | the same GET URL on a public bucket |
.createSignedUrl(path, 3600) | POST /storage/sign/avatars/{path} with {"expires_in": 3600} |
.list(prefix) | GET /storage/objects/avatars?prefix= |
.remove([path]) | DELETE /storage/objects/avatars/{path}, one per call |
Supabase storage policies on (storage.foldername(name))[1] = auth.uid() map to an owner bucket, which requires keys to start with the user id.
Edge functions
supabase.functions.invoke("hello", { body }) becomes POST /functions/hello. Both run Deno, and Deno.serve code carries over. Set secrets with berth env set instead of supabase secrets set, and read the injected BERTH_SECRET_KEY instead of SUPABASE_SERVICE_ROLE_KEY. npm:, jsr:, and https:// imports work as they do on Supabase. A function is one file, so there is no shared _shared folder: bundle local imports into the file first.
Scheduled jobs
On Supabase a scheduled job is usually pg_cron plus pg_net calling an edge function over HTTP with the service role key. On Berth the schedule belongs to the function itself, and no key travels in SQL:
| Supabase | Berth |
|---|---|
cron.schedule('job', '*/15 * * * *', $$ select net.http_post(...) $$) | berth functions deploy NAME FILE --schedule "*/15 * * * *" |
cron.unschedule('job') | --no-schedule, or "schedule": null |
cron.job_run_details | berth functions logs NAME |
| checking the service role key in the function | checking Berth-Key-Type: schedule |
cat > nightly.ts <<'EOF'
export default async (req: Request) => {
if (req.headers.get("Berth-Key-Type") !== "schedule") return new Response(null, { status: 403 });
const { scheduled_at } = await req.json();
return Response.json({ ran_for: scheduled_at });
};
EOF
berth functions deploy --app "$APP" nightly nightly.ts --schedule "0 3 * * *" --json | jq -e '.function.schedule == "0 3 * * *"'
berth functions deploy --app "$APP" nightly nightly.ts --no-scheduleSchedules are in UTC and fire at most once a minute. Details are on Functions.
Realtime
A channel().on("postgres_changes", { event: "*", table: "notes" }) subscription becomes an EventSource on /tables/notes/stream, with insert, update, and delete events. See Realtime.
Database webhooks
Supabase database webhooks map to Berth webhooks: pick a table and events, and Berth POSTs signed JSON with record and old_record, with retries.
Moving the data
supabase db dump has a Berth counterpart in berth apps export, which writes a pg_dump file. To bring data in from Supabase, export each table as CSV and load it with berth import, or insert in batches of 1,000 rows. Create the tables with berth tables create first so they get Berth's ids, timestamps, and policies.
printf 'title,done\nImported from Supabase,false\n' > notes.csv
berth import --app "$APP" notes notes.csv
berth apps export "$APP" -o notes_app.dumpWhat Berth does not have
- No embedded joins.
select("*, profiles(*)")has no equivalent. Make two requests, or join in SQL or a view from your server. - No OAuth or social login. Email and password, and email codes, only.
- No
or=filters. Filters are ANDed. Usein.(...)or SQL. - No phone or SMS auth.
- No presence or broadcast channels. Realtime is row changes only.
- No client SDK. Plain HTTPS and the CLI.
- No custom policy expressions in the API. The four policy levels, plus functions for anything else.