Motherbase has a built-in Supabase importer that moves a whole project, not only its database.
Six sections, three credentials. Each credential unlocks more of the import, and each section is independent: a missing credential downgrades that section to a note instead of failing the run, and a section that fails never takes another down.
| Credential | What it brings across |
|---|---|
| Postgres connection string (required) | the public schema, your other non-Supabase schemas, table data, RLS policies, email/password users from auth.users, and the bucket catalog |
service_role key | the storage objects' bytes. Without it, buckets are created empty |
Management API token (sbp_…) | edge functions, ported from Deno, and project secrets |
Still not copied: Vault entries, realtime config, auth provider settings, and Supabase-managed internal schemas.
The importer treats the Supabase connection string as a secret. It is sent to the target project admin API, used server-side for pg_dump, psql, and read-only auth queries, then discarded. Temporary dump files are written under BACKUP_DIR and removed after the run.
Before you import
You need:
- a running Motherbase project
- access to its Platform Console or direct admin token
- a Supabase Postgres connection string with read access to
public— use the session pooler URI on port 5432, copied from Supabase's Connect panel. The other two do not work: the direct connection (db.<ref>.supabase.co) is IPv6-only and fails to resolve from most hosts, and the transaction pooler (port 6543) cannot runpg_dump - read access to
auth.usersif you want to import users pg_dumpandpsqlavailable in the Motherbase API runtime, which the official image includes
Create a backup of the empty or current target project first:
motherbase db dump before-supabase-import.sqlFor a production migration, run the first import into a fresh staging Motherbase project. Validate schema, RLS, auth, and app flows before switching traffic.
Link the target project
Log in to the Platform Console, then link the target project:
motherbase login --url https://console.example.com
motherbase projects list
motherbase link my-project
motherbase statusFor CI or server automation, you can skip local config:
MOTHERBASE_CONSOLE_URL=https://console.example.com \
MOTHERBASE_TOKEN=$MOTHERBASE_TOKEN \
MOTHERBASE_PROJECT=my-project \
motherbase statusDirect single-project mode also works:
MOTHERBASE_URL=https://my-project.example.com \
MOTHERBASE_ADMIN_TOKEN=$MOTHERBASE_ADMIN_TOKEN \
motherbase statusPreview first
Run a dry-run preview. It counts everything it would import, without writing to Motherbase.
motherbase import --from-supabase "$SUPABASE_DB_URL" --service-role-key "$SUPABASE_SERVICE_ROLE_KEY" --management-token "$SUPABASE_ACCESS_TOKEN" --preview --jsonThe preview returns:
{
"tables": [
{ "name": "todos", "rows": 1200 }
],
"policies": 6,
"authUsers": 42,
"buckets": [
{ "name": "avatars", "objects": 310, "bytes": 48211904 }
],
"edgeFunctions": ["send-receipt"],
"secrets": 4,
"notes": []
}If auth.users cannot be read, the preview still works for schema/data and adds a note that auth import will be skipped or fail.
Run the import
Drop --preview and keep the credentials you used for it:
motherbase import --from-supabase "$SUPABASE_DB_URL" --service-role-key "$SUPABASE_SERVICE_ROLE_KEY" --management-token "$SUPABASE_ACCESS_TOKEN" --yes --jsonThe CLI sends this admin request to the linked project:
POST /api/admin/import/supabase
Content-Type: application/json
{
"connectionString": "postgresql://...",
"serviceRoleKey": "eyJ...",
"managementToken": "sbp_...",
"include": {
"schema": true, "data": true, "auth": true,
"storage": true, "edgeFunctions": true, "secrets": true
},
"confirm": "IMPORT"
}For a dry-run through the API:
{
"connectionString": "postgresql://...",
"include": {
"schema": true, "data": true, "auth": true,
"storage": true, "edgeFunctions": true, "secrets": true
},
"preview": true
}The response summary reports every section separately, under an overall status of success,
partial or failed:
{
"status": "partial",
"schema": { "applied": true, "warnings": "..." },
"data": { "applied": true, "includedInDump": true },
"auth": { "imported": 42, "skipped": 0, "errors": [] },
"storage": { "buckets": 2, "objects": 310, "skipped": 0, "errors": [] },
"edgeFunctions": { "imported": 1, "enabled": 1, "needsPorting": 0, "errors": [] },
"secrets": { "imported": 4, "skipped": 1, "errors": [] },
"notes": []
}partial means at least one section did not fully land — read its errors and the notes
before switching traffic.
schema.warnings means psql reported per-statement issues while the import continued. Review them before going live.
Partial imports
Schema + auth, without table data:
motherbase import --from-supabase "$SUPABASE_DB_URL" --no-data --yesSchema + data, without auth users:
motherbase import --from-supabase "$SUPABASE_DB_URL" --no-auth --yesEach section has its own --no- flag: --no-data, --no-auth, --no-storage,
--no-edge-functions, --no-secrets.
The admin API supports the same control with include:
{
"include": { "schema": true, "data": false, "auth": true, "storage": false }
}What happens internally
Schema and data use pg_dump from Supabase and psql into the Motherbase project database.
The dump is restricted to the portable app schema:
--schema=public
--no-owner
--no-privileges
--exclude-schema=auth
--exclude-schema=storage
--exclude-schema=graphql
--exclude-schema=realtime
--exclude-schema=vault
--exclude-schema=supabase_functions
--exclude-schema=supabase_migrationsYour other schemas come along with public. Supabase's own RLS guide tells you to keep
SECURITY DEFINER helpers out of public, usually in a schema named private, and to call
them from policies and triggers. Dumping public alone would produce a dump that cannot load
(schema "private" does not exist), so the importer dumps every schema you defined and skips
only the Supabase-managed ones. The summary lists which extra schemas came across. What those
helpers call in a Supabase-managed schema (net.http_post, cron.schedule) still has no
equivalent, and fails at runtime rather than at import.
Three rewrites happen on the way in:
- Supabase roles are created for you. A
publicdump grants to roles such asservice_roleandauthenticatorthat a fresh project does not have. The importer pre-creates them as privilege-freeNOLOGINroles so those grants parse. They hold nothing beyond what the dump itself grants. - The dump preamble is trimmed of settings this project's PostgreSQL does not recognise, so
a source on a newer PostgreSQL (a
SET transaction_timeoutfrom a PG17 source, for example) does not abort the load.CREATE SCHEMA publicbecomesCREATE SCHEMA IF NOT EXISTS public, since every project database already has that schema and the bare statement would roll the whole import back. auth.uid()in imported policies is cast — see RLS compatibility.
Schema and data load atomically, auth users do not. The dump is applied in one transaction
with ON_ERROR_STOP, so a single failing statement rolls back the whole schema and data load
while the independent user import still succeeds. That is the "only my users got imported"
outcome, and the summary names the statement that caused it.
After the load the importer re-reads both databases and compares tables, row counts,
constraints, RLS state and policies, reporting the import as failed if they disagree. That
comparison ignores the auth.uid() cast below (it is the importer's own doing) and nothing
else. It runs after psql has committed: a verification failure means the objects are in
your project and the comparison disagreed, not that nothing happened.
Auth users are imported separately from auth.users:
idis preserved so existing owner columns can keep matchingauth.uid().emailbecomesemailandclassicEmail.encrypted_passwordis stored aspasswordHash.- bcrypt hashes are accepted on first login and migrated to argon2 after a successful password check.
- existing Motherbase emails are skipped, not overwritten.
- usernames are generated from the email local part and deduplicated.
RLS compatibility
Supabase and Motherbase both support auth.uid(), auth.jwt(), anon, and authenticated, but there is one important type difference: Motherbase auth.uid() returns text.
Left alone, the first imported policy comparing a uuid column to auth.uid() would fail with
operator does not exist: uuid = text:
-- Supabase-style policy against a uuid user_id
USING (auth.uid() = user_id)The importer therefore casts each auth.uid() in the dump to (auth.uid())::uuid, which
reproduces the source's own typing: an expression that was uuid on Supabase stays uuid, and
one written as auth.uid()::text stays text. You do not have to fix imported policies by
hand.
Policies you write yourself in Motherbase compare auth.uid() as text — only imported ones
are cast. For new tables, standardize on text owner columns:
user_id text DEFAULT auth.uid()Also review policies that reference Supabase-only schemas or functions, for example storage.objects, auth.users, vault, extensions, realtime, or custom extension schemas.
Storage migration
Buckets and objects are part of the import. Buckets come from the source's storage.buckets
catalog over the same connection the dump used, with their public/private visibility. Object
bytes come from the Storage API, which is the only place they exist, so they need the
service_role key.
Without the key the buckets are still created, empty, and the summary says how many objects were left behind. Re-running the import with the key moves them: a re-run reuses the bucket slugs from the earlier one, which is what makes it resumable.
Two things to watch in the summary:
- Bucket names are slugified.
my_bucketandmy-bucketare two distinct Supabase buckets that both map tomy-bucket, so the second gets a-2suffix rather than merging into the first and overwriting its files. Object keys are untouched, but client code addressing the bucket by name has to follow the rename, and the summary lists every one. - Stored Supabase storage URLs are rewritten to point at this project after the objects land.
Object keys are unchanged, so rows that already store a path keep working, and application code ports as is:
await client.storage.from('avatars').upload(`${userId}/avatar.png`, file, { upsert: true })
const { data } = await client.storage.from('avatars').download(`${userId}/avatar.png`)
const files = await client.storage.from('avatars').list(userId)Keys are normalised and traversal-checked, so ../ in a key is refused rather than cleaned up:
a key that needed rewriting is not the key you meant. The server-issued file id still works
anywhere a path does, which keeps code written before path addressing running.
What is different:
{ transform }works ondownload,getPublicUrlandcreateSignedUrl, served by/storage/v1/render/imagewith the Supabase limits (width/height up to 2500,cover/contain/fill, quality 20–100,origin/webp/avif; no SVG output). Withoutformat, WebP is negotiated fromAccept.upsertworks: without it, a second write to the same key is a409rather than a silent replacement.contentTypeworks too.cacheControlis not part ofUploadOptions, so a ported call naming it is a type error rather than an option quietly dropped. Cache headers come from the driver, and from the render endpoint for a transform.- Bucket administration from the client (
createBucket,listBuckets,deleteBucket, …) needs a secret key, so it is a server-side call. See the SDK reference.
Supabase policies on storage.objects do port: turn on storage RLS and write them against
storage.files (or the storage.objects view over it). See
Storage → Authorization.
Edge functions and secrets
Both need a Supabase personal access token (--management-token, from
your Supabase account tokens).
Each function keeps its slug, which is its route: a function called at
/functions/v1/send-receipt on Supabase is called at /api/edge/send-receipt here, so client
code only changes its base URL. The Deno source is ported on the way in.
A function is imported enabled only if the ported source passes the same deploy-time bundle
gate the Studio deploy button runs. Anything else is stored disabled and counted in
needsPorting, so the migration stays lossless and you finish the port by hand. When the port
itself never got tested (an unresolvable import short-circuits the deploy), the rewritten source
is kept as a head start; when a deploy was tried and failed, the original source is kept, because
then the rewrite is the prime suspect.
A function whose name or route already exists here is skipped rather than overwritten, and shows
up in edgeFunctions.errors.
Project secrets land in this project's edge-function secret store. Supabase-managed SUPABASE_*
secrets are skipped on purpose: they point at the source project. Imported functions should use
this project's own URL and keys.
App code changes
Replace the Supabase package with Motherbase:
npm remove @supabase/supabase-js
npm install @motherbase/client
npm install socket.io-client # only if you use realtimeThen let the CLI do the import rewrite and tell you what will not port:
npx motherbase migrate codemod ./src # dry run, prints what it would change
npx motherbase migrate codemod ./src --write # applyIt rewrites @supabase/supabase-js to @motherbase/client and @supabase/ssr to
@motherbase/ssr, then reports every call this SDK does not implement, with a file, a line and
what to do instead. It is a text pass, not an AST pass, so a match inside a comment or a string
is still reported — over-reporting in a list you read yourself beats missing one.
Initialize the client:
import { createClient, localStorageStorage } from '@motherbase/client'
export const client = createClient(
process.env.NEXT_PUBLIC_MOTHERBASE_URL!,
process.env.NEXT_PUBLIC_MOTHERBASE_ANON_KEY,
{ storage: localStorageStorage() },
)
await client.hydrate()Most CRUD calls stay the same:
const { data, error } = await client
.from('todos')
.select('*')
.eq('done', false)
.order('created_at', { ascending: false }).rpc(), relational embedding, the full filter set, broadcast and presence
channels, OAuth helpers and storage.list() all exist — most application code
compiles unchanged after the package swap.
The differences that will actually bite are behavioural rather than missing:
| Difference | What to do |
|---|---|
An unbounded select() is capped at api.rest.maxRows, 1000 by default | Paginate with .range(from, to) and read Content-Range, or pass an explicit .limit() |
| A write returns no rows unless asked, as in PostgREST | Keep the .select() after a write wherever you read its result; without it data is null |
An unfiltered delete() is refused with 400 | Add a filter. This is deliberate: supabase-js would delete every visible row |
client.schema('other').from() throws | Tables are served from public only — use a view in public. client.schema(name).rpc() does work, for the schemas a feature exposes (Queues' pgmq_public); any other is refused with PGRST106 |
| Better Auth sessions have no refresh token | refreshSession() revalidates rather than rotating |
explain() is admin-only and answers 403 for a normal caller | Do not ship code that depends on it |
There is no pg_graphql. If you query Supabase over GraphQL, that path has no equivalent and
has to be rewritten as REST calls.
The authoritative, per-method list is
COMPATIBILITY.md,
generated by a contract test that runs every listed supabase-js call against a
live server. Do not trust a hand-written table, including this one, over that.
Regenerate your types
npx motherbase gen types typescript > src/database.types.tsThe emitted shape is the supabase-js Database shape (Row, Insert, Update,
Relationships, Enums, Functions), so createClient<Database> and every from() /
rpc() call stay typed exactly as they were.
Port auth
Password, email OTP, OAuth, password reset, updateUser, getUser, onAuthStateChange and
session persistence all port directly. Sessions are Better Auth bearer sessions, which is
where the remaining differences come from:
- No refresh token.
refreshSession()revalidates the current token rather than rotating a refresh token, andsetSession()ignores therefresh_tokenfield it accepts for signature parity. The SDK revalidates on a401by itself (single-flight) and clears the session withSIGNED_OUTwhen the token is dead. - PKCE is supported. Create the client with
flowType: 'pkce', start OAuth/SSO with a redirect, then callexchangeCodeForSession(code)in the same browser that started the flow. The SDK stores and consumes the S256 verifier once. Implicit flow remains the default whenflowTypeis omitted. - Password reset is a one-time code, not a redirect link:
resetPasswordForEmail(email)thenresetPassword({ email, otp, password }). - A password change also requires
currentPassword, and email changes are not supported. verifyOtphandlestype: 'email'and SMS codes; the other supabase-js channels do not exist.- Two-factor (
auth.mfa.*), enterprise SSO (signInWithSSO), user administration (auth.admin.*, secret key required), identity linking (getUserIdentities,linkIdentity,unlinkIdentity),signInWithIdToken,resend, anonymous sign-in andgenerateLinkall port — see the SDK reference. Anonymous sign-in must be enabled inauth.methods.anonymous; it creates a user rather than ananonrequest.
Port realtime
client.channel(name).on('postgres_changes' | 'broadcast' | 'presence', …), .subscribe(),
.send(), .track(), .presenceState() and removeChannel() are API-identical. Three gaps:
- Private channels (
config: { private: true }) are not modelled; the flag is dropped. Authorize with RLS on the underlying table instead. - Only those three event types are accepted.
.on('system', …)throws. postgres_changesis fed by the API, not by the WAL, so a write made inside an.rpc()function body, by a trigger, or from the SQL editor emits nothing. See Realtime → Delivery.
The transport is Socket.IO, not the Supabase Realtime wire protocol. That is invisible through the SDK, but any non-JS client speaking the Supabase wire protocol directly will not connect.
Port edge functions
client.functions.invoke(name, { body, method, headers }) ports directly. region is accepted
and warned about, then ignored: a self-hosted Motherbase runs every function in one place. A
failing function still resolves { data: null, error } rather than throwing, but error is a
real FunctionsHttpError / FunctionsFetchError instance, so an instanceof check ported from
supabase-js keeps working.
Keys and service_role
Supabase's anon key becomes a publishable key, and the service_role key
becomes a secret key. Both are on Project Settings → API keys.
// Browser: publishable key, RLS decides everything (as `anon` did)
createClient(url, process.env.NEXT_PUBLIC_MOTHERBASE_ANON_KEY)
// Server: secret key, runs as service_role and bypasses RLS
createClient(url, process.env.MOTHERBASE_SECRET_KEY!)One scope difference to plan for: a secret key opens the data plane and
client.auth.admin.*, but not SQL or DDL. Code that used the service key to
run arbitrary SQL has to move to a migration, the CLI or the admin API. See
API Keys.
Note also that key enforcement is off by default here, so a project accepts callers that send no key at all until you turn it on.
Post-import checklist
Run these checks before switching users over:
motherbase db tables
motherbase users list
motherbase rls list
motherbase storage buckets list
motherbase inspect advisor
motherbase inspect shieldThen verify in the app:
- users can sign in with existing email/password accounts
- owner-scoped rows still match
auth.uid() - anonymous routes only expose data intended for
anon PATCHandDELETEcalls include filters- realtime subscriptions receive expected table events
- storage references point to uploaded Motherbase file ids
- any
schema.warningsfrom the import are resolved or accepted
Rollback
If the target project was empty, the cleanest rollback is to delete and recreate it from the Platform Console.
If you imported into an existing project, restore the backup you created before import:
motherbase db restore before-supabase-import.sql --confirm