CapyDB/ docs
GuidesImports & Migrations

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-jsdrizzlenotes
.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 === 1no 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(*) queryPostgREST 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.

  1. Copy the objects to the new store - any S3-compatible object store works, and Supabase Storage speaks the S3 protocol, so rclone or the AWS CLI moves a bucket wholesale.
  2. Swap the call sites: uploads, getPublicUrl, signed URLs. If your buckets were private, note that the access rules lived as RLS on storage.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.
  3. 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.
  4. Update image allowlists - next/image remotePatterns (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 refetch interval 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/NOTIFY on a direct :5432 connection - transaction pooling does not carry LISTEN) 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 withAuthContext if 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 LISTEN connection) 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.