Supabase → Mortar migration guide
Status: cookbook (documentation). No automated import tool —
pg_dumpis already a good tool, and migration is mostly a config + DNS-switch problem, not a data problem.
Target audience: China-market React Native apps currently running on Supabase, forced to migrate because of stability issues accessing Supabase from mainland China + overseas billing settlement.
One-shot evaluation
# The Mortar CLI ships with an evaluator (planned; run manually for now):
mortar migrate evaluate --supabase-url=$SUPABASE_URL --supabase-key=$SUPABASE_KEY
# Output: which Supabase features your project uses + Mortar equivalents + risk points
Feature mapping
| Supabase | Mortar equivalent | Difficulty |
|---|---|---|
| Database (PostgREST API) | mortar.from(...) (@mortar/client) | Easy — 1:1 |
| Auth (email / OAuth) | mortar.auth | Easy — email is fine; OAuth, you wire each provider yourself |
| Storage (S3-compatible) | mortar.storage | Easy — same API shape, buckets/keys migrate directly |
| Realtime (postgres_changes / broadcast / presence) | mortar.realtime (subscribe / channel().on('broadcast') / channel().track()) | Easy — full triad; see channel pitfall below |
| Edge Functions | mortar.compute | Medium — rewrite Deno → Node/FC |
| Vector (pgvector) | Same (pgvector) | Easy — same extension |
| Cron | Planned | For now, use Mortar compute + a third-party cron scheduler |
| GraphQL | ❌ Not supported | Mortar is REST + mortar.from(table) row API; Supabase projects using pg_graphql need to migrate to REST |
| Triggers / Webhooks | Postgres triggers + mortar.compute | Medium |
| Branching | Planned | Manual pg_dump for now |
| Stripe billing | WeChat Pay / Alipay | Not the same — mainland customers use Chinese payment rails |
Data migration: three steps
1. Copy the database
# Pull from Supabase
pg_dump $SUPABASE_DB_URL \
--no-owner --no-acl --clean --if-exists \
-f supabase.sql
# Push to Mortar
psql $MORTAR_DB_URL -f supabase.sql
Notes:
- Supabase stores users in the
auth.usersschema; Mortar uses theapp_userstable. Write an INSERT SELECT to map fields across. Both use bcrypt, so passwords carry over without forcing users to reset. - Supabase’s
storage.objectstable holds metadata; the actual files live in an S3-compatible bucket. Useaws s3 syncorrcloneto pull objects locally, then push them to OSS. - RLS: the two isolation models differ. Supabase gives each project
its own database — tenants are isolated by physically separate
databases, with per-user RLS (
auth.uid()) inside each one. Mortar uses a shared database + atenant_idcolumn + RLS: isolation is a platform-applied tenant_id-level policy (tenant_id = auth.tenant_id(), whereauth.tenant_id()is Mortar’s analog of Supabase’sauth.uid(), and its value is the project’s UUID) — you don’t author per-table policies. So Supabase’s per-userauth.uid() = user_idpolicies don’t copy verbatim: Mortar V0 stores data in the genericapp_rowsJSONB model and the RLS grain is the project’s tenant_id, not the end-user. Move that row-level authorization intent into the app layer (end-user JWT + dedicated endpoints); tenant_id isolation is the platform backstop.
2. Copy storage objects
# Supabase uses S3-compatible (internally R2); use rclone
rclone copy \
:s3,provider=Other,access_key_id=X,secret_access_key=Y,endpoint=https://supa.r2.cloudflarestorage.com/:bucket \
:s3,provider=Aliyun,access_key_id=X,secret_access_key=Y,endpoint=https://oss-cn-hangzhou.aliyuncs.com:keel-bundles/
3. DNS cutover + client SDK swap
// Before
import { createClient } from '@supabase/supabase-js';
const supa = createClient(SUPABASE_URL, SUPABASE_KEY);
const { data } = await supa.from('users').select('*');
// After
import { createClient } from '@mortar/client';
// The tenant lives in the host subdomain (Supabase's <ref>.supabase.co
// model) — there is no separate `tenant` option.
const m = createClient({ url: 'https://<tenant>.api.mortar.appunvs.com', apiKey: '...' });
const { data } = await m.from('users').select('*');
@mortar/client’s surface is deliberately close to supabase-js —
the chainable API (.from(table).select() / .insert() / .update() / .delete() / .eq() / .gte()) is the same.
Common pitfalls
1. Realtime channel naming + split send/receive
- Row changes: Supabase uses
postgres_changes:public:tablename; Mortar usesmortar.realtime.subscribe({table})(subscribe to one table’s row changes). - broadcast / presence: Supabase is
channel.on('broadcast'…)+channel.track(), all bidirectional over a single WebSocket. Mortar’smortar.realtime.channel(name)has the same API shape, but underneath it’s SSE-receive + HTTP-POST-send (Mortar uses no WebSocket) — transparent to the caller, behaviorally equivalent. When migrating,channel.send({type:'broadcast',…})/track()/presenceState()port over essentially verbatim.
2. Auth provider list
Supabase ships 20+ built-in OAuth providers; Mortar currently ships only email + WeChat + Apple Sign-In. For Google / Facebook / GitHub:
- Run a PKCE flow client-side with
@keel-ai/auth-session - POST the resulting OAuth code to Mortar
auth/oauth/callbackand store it yourself
Built-in OAuth orchestration is planned.
3. Edge Function runtime
Supabase Edge Functions = Deno on Cloudflare Workers.
Mortar compute = Aliyun FC (Node 20) / Tencent SCF (Node 16/18).
You’ll need to:
- Replace Deno-specific imports (e.g.
https://deno.land/x/*) with npm - Change
Deno.env.get()→process.env.X - Top-level
awaitin TypeScript — Node 20 supports it; no change needed
4. Billing model differences
Supabase charges a fixed monthly fee (from $25/mo) + over-quota;
Mortar charges by usage (from ¥99/mo) + tier upgrades. See
pricing.md for a detailed comparison.
Real migration timeline
Below is a real customer migration (an alpha customer during the early dogfood phase):
| Step | Time | Notes |
|---|---|---|
| Schema dump + restore | 30 min | 12 tables, 5 GB |
| File object sync | 4 h | 80 GB OSS upload |
| SDK swap | Half day | 3000 lines of supabase-js calls → @mortar/client; mostly channel names |
| Realtime cutover | 30 min | DNS TTL + ~5 min wait |
| Gradual rollout (10% → 50% → 100%) | One week | No incidents in between |
| Total | 8 working days | One engineer |
Reverse migration
Mortar → Supabase is also supported (decoupling responsibility). Data
via pg_dump; metadata via Mortar mortar export (planned).
No vendor lock-in.
Pitfalls that go away later
- Cron → equivalent to Supabase
- Database branches → git-style, same as Supabase