Skip to content

SDK Compatibility

A strategic goal of AnvilBase is to make the Supabase client libraries work drop-in on the data plane, so migrating from Supabase is trivial. This page is the honest compatibility matrix and the connection recipe.

TL;DR

SDK moduleStatusNotes
Database (.from().select()…)✅ drop-inPostgREST-compatible engine
RPC (.rpc())✅ drop-incalls Postgres functions
Storage (.storage.from()…)✅ drop-inS3-compatible, SDK paths supported
Realtime (.channel()…)✅ drop-inPhoenix protocol matches realtime-js
Auth (.auth.signInWithPassword()…)✅ drop-in (facade)GoTrue-compatible facade over Better Auth; see caveats below

So all five SDK modules — data, RPC, storage, realtime, and auth — are drop-in against the same createClient base URL.

Connecting

The SDK base URL is your host plus /v1/<project_id>; the key is the project’s anon key:

import { createClient } from '@supabase/supabase-js'
const db = createClient(
'https://anvilbase.example.com/v1/<project_id>',
'anvilbase_anon_<slug>_…',
)

From here, db.from(...), db.rpc(...), db.storage.from(...), and db.channel(...) all work as you’d expect from Supabase. See Connect Your App for TS/Python/Dart/curl examples of each.

How the data-plane paths line up

The control plane accepts both the native AnvilBase shape and the SDK-composed shape, so the SDK’s internal path construction resolves correctly:

SDK callResolves to
db.from('t').select()/v1/<id>/rest/v1/t → the REST→SQL engine
db.storage.from('b').upload()/v1/<id>/storage/v1/object/b/...
db.channel(...).subscribe()WebSocket to /v1/<id>/realtime/v1/...

You don’t construct these by hand — the point is they match, so the SDK “just works.”

How auth compatibility works

AnvilBase serves a GoTrue-compatible facade at /v1/<project_id>/auth/v1/* — the exact paths supabase-js’s auth module calls. So db.auth.* works unmodified on the same client:

const db = createClient(`${HOST}/v1/${PROJECT_ID}`, ANON_KEY)
await db.auth.signUp({ email, password })
const { data } = await db.auth.signInWithPassword({ email, password })
// db is now authenticated — the SDK stores the session and sends the
// Authorization header on every data/storage/realtime request itself.
await db.auth.signInWithOtp({ email }) // magic-link / email OTP
await db.auth.signInWithOAuth({ provider: "github" }) // 8 providers
await db.auth.signInWithIdToken({ provider: "apple", token }) // native mobile
await db.auth.mfa.enroll({ factorType: "phone", phone }) // SMS second factor
await db.auth.getUser()
await db.auth.signOut()

Non-SDK clients can hit the facade endpoints directly — e.g. the password grant:

Terminal window
curl -X POST "$HOST/v1/$PROJECT_ID/auth/v1/token?grant_type=password" \
-H "apikey: $ANON_KEY" -H "Content-Type: application/json" \
-d '{"email":"alice@example.com","password":"s3cret"}'
# → { "access_token": "<jwt>", ... }

See Auth → Users & Sessions for the full endpoint list.

Auth compatibility notes. The standard flows are drop-in, and the service-role supabase.auth.admin.* user-management surface is served too (it requires the project service_role key). OAuth spans eight providers (github, google, apple, discord, microsoft, facebook, twitter, linkedin); native id-token sign-in (signInWithIdToken) is supported for the providers with server-side id-token verification — apple, google, facebook, microsoft (others return 400 provider_id_token_not_supported); and MFA supports both TOTP and phone (SMS) factors (mfa.enroll({ factorType: 'phone', phone })). One edge to know: a user created via OTP / magic-link sign-in does not capture signup-time options.data metadata at first verify — password and anonymous signup persist it immediately; for OTP/magic-link, call updateUser({ data }) after the first sign-in. See Auth → Overview.

Other languages

The same model applies to the Supabase clients in other languages — point them at /v1/<project_id> with the anon key; their auth modules speak the same GoTrue facade, so sign-in works through the client:

  • Python (supabase-py) — create_client(url, anon_key), then client.auth.sign_in_with_password(...).
  • Dart/Flutter (supabase_flutter) — Supabase.initialize(url:, anonKey:).
  • Any HTTP client — it’s plain REST; see Connect Your App → Raw HTTP.

What to watch for

  • @supabase/ssr server helpers: configure them for the PKCE flow (flowType: 'pkce') against the /v1/<project_id> base URL. onAuthStateChange is client-local and fires normally on the SDK’s own sign-in/out/refresh events.
  • Storage logical buckets are key prefixes; the SDK’s from('<bucket>') maps cleanly.
  • Realtime topics follow the realtime:public:<table> convention; the SDK builds these for you. The native @anvilbase/client SDK supports broadcast and presence (channel.on('broadcast'/'presence', …), channel.send(), channel.track()/untrack(), channel.presenceState()) in addition to postgres_changes, matching supabase-js.

Automated compatibility harness

AnvilBase ships an automated harness that installs the real @supabase/supabase-js and exercises all five SDK surfaces end-to-end against a running stack — it’s the executable version of this compatibility matrix. It lives in tests/supabase-js-compat/.

SurfaceCapabilityStatus
RESTinsert / .insert().select().single()
RESTbulk array insert
RESTselect + chained .eq() filters
RESTnested embed (authors→books)
RESTcount=‘exact’ (+ head:true HEAD)
AuthsignUp
AuthsignInWithPassword
AuthgetUser
AuthsignInWithOtp (magic-link/OTP)
Authadmin.createUser/listUsers/getUserById/deleteUser
Authadmin isolation (anon → 401/403)
AuthsignInWithIdToken (id_token grant)
StoragecreateBucket / listBuckets
Storageupload / list / getPublicUrl / createSignedUrl
Storagecopy / move
Storagebulk .remove([…])
Realtimeserver-side POST /api/broadcast receive⚠️ skip
Realtimepostgres_changes (INSERT event)⚠️ skip
Realtimepresence track()/presenceState()⚠️ skip
Realtimeclient channel.send broadcast (self)⚠️ skip
Functionsinvoke() (verify_jwt=true)
Functionspublic invoke (verify_jwt=false)

✅ asserted green by the real @supabase/supabase-js SDK · ⚠️ probe skipped (stack predates the backing fix) · ❌ not exercised / failing. This table is generated by tests/supabase-js-compat/gen-matrix.ts from the harness’s actual result set — do not edit by hand.

Run it against any stack:

Terminal window
cd tests/supabase-js-compat
bun install
export ANVILBASE_URL=http://localhost:39001
export ANVILBASE_ADMIN_TOKEN=<your ANVILBASE_ADMIN_TOKEN> # auto-provisions a project
bun test --timeout 60000

Or against an existing project with ANVILBASE_PROJECT_ID + ANVILBASE_ANON_KEY. In CI it runs as a labeled (ci:compat) and nightly lane that brings up an isolated compose stack. See the harness README.md for details.

Separately, AnvilBase’s own native client SDKs (TypeScript, Python, Go, Rust, Kotlin, Elixir) and the MCP server each have unit suites that run on every push/PR via the sdks.yml lane; the Swift SDK runs on a macOS runner gated by the ci:swift label / nightly (macOS minutes are costly). See the CI lanes table in the README.

Resolved wire-compatibility fixes (9.8)

The five remaining wire divergences the harness surfaced are now fixed and asserted hard, so these all behave exactly as @supabase/supabase-js expects:

  • .insert().select().single() returns a single object (201 Created), not an array — the POST honors the single-object Accept; 0 or >1 affected rows return 406 PGRST116, matching PostgREST. The same applies to .update().select().single() / .delete().select().single().
  • Bulk array insert (.insert([row1, row2, …])) works — the SDK’s ?columns= hint is parsed and used to fix the column set, so heterogeneous / sparse rows insert in a single statement (omitted columns take their DEFAULT/NULL).
  • count with head: true (an HTTP HEAD) returns the count in Content-Range with an empty body.
  • Storage upload byte round-tripmultipart/form-data uploads (what the SDK sends for a File/Blob) store the inner file bytes, so a later download is byte-identical. Raw (non-multipart) uploads still round-trip verbatim.
  • Storage createSignedUrl — returns a control-plane-routed, client-reachable signed URL (an expiring HMAC token is the credential); fetching it returns the object, for private buckets too.
  • Storage .remove() — the SDK’s bulk delete (DELETE /object/{bucket} with a { prefixes } body) is implemented; storage.from(b).remove([...]) works for any number of paths, authorized per key.

Migrating an existing app

If you already have a Supabase app, see Migrate from Supabase for the step-by-step swap (often just the client URL/key — supabase.auth.* works unchanged).

Next: Migrate from Supabase.