Both are good, and they are built for different worlds. This page is the practical mapping; the side-by-side is the decision.
What maps cleanly
Supabase vs Pithy is the comparison, the data layer is what replaces Postgres and PostgREST, who owns your data is the difference that decides it, and coming from a Node backend covers the runtime.
| Supabase | Here |
|---|---|
| Auth — sessions, OAuth, API keys | auth, on Better Auth |
| Storage buckets, signed URLs | storage, on R2 |
| pgvector | vector, on Vectorize |
| Vault | secrets, on an encrypted D1 |
| Edge Functions | Your own routes, in your app capability |
The big one: Postgres becomes SQLite
D1 is SQLite. That is the migration, and everything below follows from it.
No extensions. No stored procedures. No pgvector in the same database as your rows — vectors live in Vectorize and you join on the id.
No interactive transactions. BEGIN/COMMIT around application logic is not available, so correctness moves into the statement: a CHECK a race cannot walk past, a conditional INSERT … SELECT … WHERE that evaluates its own precondition, a DELETE … RETURNING exactly one concurrent caller wins.
100 bound parameters per query, which a generated IN (…) over a user list will meet.
Row Level Security has no equivalent, and that is deliberate
There is no policy engine in the database. Authorization lives in your Worker, at the route.
That is a real trade, in both directions:
You lose a single place that enforces access regardless of which client connected — because there is no client connecting directly to your database.
You gain the reason that mattered: nothing connects directly to your database. There is no anon key, no client-side query, and no policy to get wrong on a table somebody added last week. The Worker is the only reader.
What you write instead
app.get("/boards", requireAuth(), async (c) => {
return c.json(await c.var.db.app
.selectFrom("boards")
.where("ownerId", "=", c.var.auth.userId)
.selectAll().execute());
});The predicate is in the query, and the identity comes from the seam. It is the same rule RLS enforces, written where you can read it.
Realtime is not the same thing
Supabase pushes database changes to a connected client.
Here, a WebSocket is a Durable Object you write — multiplayer’s session socket, matchmaking’s presence object. What gets pushed is what your server decided to push, not a change feed off a table.
If your product depends on subscribing to table changes, that is work rather than a config line.
Migrating the data
Export, transform, import. There is no adapter — Postgres and SQLite differ in types, in defaults, and in what a schema can express.
Your migrations become the kit’s: one registry, per database, with a stable migrationOrder and a tested down. See migrations.
What you give up
Someone else on call for your database. That is Supabase’s product, and it is a good one.
A mature console over your data.
Postgres itself — extensions, procedures, point-in-time recovery.
What you get
No instance to size, no pool to manage, no upgrade to schedule, and no compute billed while it idles.
And nothing of anyone else’s in the request path. Every capability executes in your Worker, against your D1, in your account.