Replacing Supabase services
What replaces supabase-js data calls, Storage, and Realtime once the database itself lives on CapyDB.
The import moves your database. Three things it
deliberately does not move: the PostgREST-shaped data client (supabase.from()), Storage, and
Realtime. capydb migrate scan counts your call sites per kind so you know the size of each
rewrite before you start - this page is what each one actually looks like.
Do the data-call rewrite first, against your current Supabase database (it is plain Postgres), so the cutover itself is only an env swap. Storage and realtime can trail the database move; the data layer cannot.
Rewriting data calls: supabase-js → drizzle/SQL
Most of supabase.from() translates mechanically. The honest exceptions are the parts that were
PostgREST grammar rather than SQL - those have no one-to-one equivalent and need a human:
| supabase-js | drizzle | notes |
|---|---|---|
.from('todos').select('id, title') | db.select({ id: todos.id, title: todos.title }).from(todos) | mechanical |
.eq('userId', id) / .gte('n', 3) | .where(eq(todos.userId, id)) / gte(...) | mechanical |
.maybeSingle() | .limit(1) then const [row] = ... | no 1:1 equivalent - row is undefined when absent, which is the same contract |
.single() | .limit(2) then check length === 1 | no 1:1 equivalent - the throw-on-zero-or-many check was PostgREST's; it moves into your code |
.select('*, author:profiles(*)') | a join, or db.query.todos.findMany({ with: { author: true } }) | no 1:1 equivalent - embedded resources were PostgREST inventing your joins. Drizzle's relational queries are the closest; otherwise two queries is often clearer than one clever join |
.or('status.eq.active,priority.gte.3') | .where(or(eq(...), gte(...))) | no 1:1 equivalent - the .or() string is PostgREST's own filter grammar; translate the meaning into SQL, don't try to port the string |
.upsert(rows) | .insert(rows).onConflictDoUpdate(...) | you now name the conflict target explicitly - which is a feature |
.rpc('fn', { x }) | db.execute(sql`select * from fn(${x})`) | the scan cross-checks every .rpc() name against local SQL and the live database - a function that exists only live must be recovered before cutover |
.range(0, 9) | .limit(10).offset(0) | mechanical; consider keyset pagination while you're in there |
{ count: 'exact' } | a separate count(*) query | PostgREST smuggled the count into a header; SQL makes it a query |
Client construction and per-request auth context are covered by
@capydb/drizzle - createDb for pooler-safe defaults,
withAuthContext if you kept your RLS.
The storage exit
Order matters here, because Supabase Storage leaks into your data: uploaded files get absolute
https://<ref>.supabase.co/storage/v1/object/... URLs, and apps persist those in columns
(image_url, avatar_url). The scan lists your buckets with object counts and sizes, and - the
part people miss - which columns hold absolute provider storage URLs. Those rows keep pointing
at Supabase until you rewrite them, which makes the storage exit a data backfill, not just a code
change.
- Copy the objects to the new store - any S3-compatible object store works, and Supabase
Storage speaks the S3 protocol, so
rcloneor the AWS CLI moves a bucket wholesale. - Swap the call sites: uploads,
getPublicUrl, signed URLs. If your buckets were private, note that the access rules lived as RLS onstorage.objects- that policy layer does not migrate (it protects a platform feature), so per-user access checks move into the route that signs or serves the file. - Then backfill the persisted URLs - a plain
UPDATE ... SET image_url = replace(image_url, 'https://<ref>.supabase.co/storage/v1/object/public/<bucket>', '<new-base>')per column the scan flagged. Run it after the import, count the rows it touched against the scan's count, and only then retire the old bucket. - Update image allowlists -
next/imageremotePatterns(or your framework's equivalent) still names the Supabase host until you change it, and the failure mode after the backfill is every image 400ing at once.
Replacing Realtime
Start by enumerating what actually listens: grep for .channel( and
postgres_changes in your code. The supabase_realtime publication on the database side is
usually a superset of that - tables get added in the dashboard and forgotten - so replace what
the code subscribes to, not what the publication contains. The scan shows both, precisely so you
can see the gap.
The replacements, plainest first:
- Polling - a
refetchinterval on the queries you already rewrote. For most dashboards and lists this is indistinguishable from a socket and there is nothing new to operate. - Server-Sent Events from a route handler - the handler watches for changes (poll a cursor
column server-side, or
LISTEN/NOTIFYon a direct:5432connection - transaction pooling does not carryLISTEN) and streams events down. One handler replaces one channel.
Two things Realtime did implicitly that you now own:
- Per-user gating. Realtime filtered events through RLS at the socket - a user only received
rows their policies allowed. Your SSE or polling endpoint must reimplement that filter: scope
the underlying query by the authenticated user (via
withAuthContextif you kept RLS, or the same app-layer guard that owns the table's reads). An unscoped SSE endpoint is a broadcast. - The always-on cost. A long-lived SSE stream (or a
LISTENconnection) holds the database busy around the clock, so a paused-when-quiet cell never pauses. For production traffic that is usually fine; for preview and development databases it silently buys you an always-awake cell. Poll only while the tab is visible, or accept the cost knowingly.
Convert Supabase RLS
Keep your row-level-security policies when you leave Supabase - capydb migrate rls converts them to portable, vanilla Postgres.
Auth systems and your migration
What your auth system means for a move to CapyDB - Clerk, BetterAuth, Auth.js, Lucia, and Supabase Auth each change the plan differently.