tinbase is live on Product Hunt today — an open-source, Supabase-compatible backend that runs without Docker. Support the launch here: [https://www.producthunt.com/products/tinbase]
The problem
Local Supabase development means Docker: a 12-container stack and over 2 GB of images to get Postgres, PostgREST, GoTrue, Storage, and Realtime running on a laptop. The platform itself is excellent; the local loop is heavy. And in some environments it isn't just heavy — it's impossible. tinbase was originally built for lifo, a project that maps Linux APIs into the browser, to let Expo apps run fully in-browser with full-stack capability — database, auth, storage, realtime, no server. It's part of the RapidNative ecosystem, and Docker was never an option in that environment.
That raised the question: what if the entire Supabase API surface were just a process? One process, embeddable anywhere Node — or a browser tab — runs?
Why Supabase compatibility instead of yet another backend
tinbase deliberately avoids inventing another proprietary BaaS API. Supabase's real strength is that it functions as an open standard: PostgREST's REST conventions, GoTrue's auth flows, and a documented realtime protocol, all served by the most widely adopted SDK in the space. Implement those protocols faithfully, and every existing supabase-js app, tutorial, and starter template works with zero changes — and every project stays portable back to hosted Supabase.
That portability is the design constraint everything else follows from. tinbase reads the same supabase/migrations/*.sql files and follows the Supabase CLI's migration conventions, tracking them in supabase_migrations.schema_migrations. It's a different runtime, not a different platform.
The architecture
tinbase is a single TypeScript process with three layers.
1. Protocol layer. Every service is a pure (Request) => Response fetch handler: the PostgREST query grammar (filters, embeds — to-one, to-many, many-to-many via junction, !inner — JSON paths, upsert, RPC), GoTrue's auth endpoints (email/password, anonymous sign-in, OAuth with PKCE, session refresh with rotation), storage APIs with signed URLs, and realtime speaking the Phoenix protocol over a hand-rolled ~150-line RFC 6455 WebSocket server. Because these are plain fetch handlers rather than a bound HTTP server, they run behind node:http (or Bun.serve) on a machine — or get handed to supabase-js as a custom fetch and invoked directly in-process, with no server at all. That one decision is what makes browser mode possible.
2. Engine adapter layer. A thin interface over "a thing that runs Postgres SQL," with three implementations and honest tradeoffs:
-
Embedded native Postgres 17 — the default on macOS/Linux. First run downloads ~12 MB of platform binaries, then
initdbwith memory-lean settings: ~59 MB of RAM at boot, listening only on a private unix socket. Real RLS, triggers, foreign keys, jsonb. - PGlite (wasm) — ElectricSQL's Postgres compiled to WASM. Zero setup, runs anywhere Node runs and in the browser, and it's the default on Windows. The tradeoff is memory: its WASM heap sits around 575–650 MB.
- pg-mem — a pure-JS in-memory subset at a 3.6 MB install, the lightest option for browser previews. No RLS or cron, but the REST CRUD surface, auth, edge functions, and realtime all work, with change events synthesized in JS by the REST layer.
3. Studio. A Supabase-Studio-style dashboard at /_/ — table editor with row CRUD, SQL editor, user management, storage, RLS policies — compiled to a single self-contained HTML file so it works even inside the single binary.
The hard parts
RLS across engines. Every REST and storage request runs inside a transaction with SET LOCAL role and request.jwt.claims, so a policy like using (user_id = auth.uid()) behaves identically to hosted Supabase — whether the SQL executes in native Postgres 17 or in WASM.
HTTP without HTTP. In a browser tab there is no listener socket. supabase-js expects a URL, so tinbase hands it a custom fetch that routes requests straight into the protocol handlers in-process:
const supabase = createClient('http://localhost', backend.anonKey, {
global: { fetch: (input, init) => backend.fetch(new Request(input, init)) },
})
Realtime without WAL. Hosted Supabase reads the write-ahead log; tinbase feeds postgres_changes from triggers plus pg_notify, and even applies per-subscriber RLS filtering on INSERT/UPDATE events by re-checking the row as that user. (DELETE events can't be re-queried — the row is gone — which is documented as a known gap.)
Proving compatibility. Claiming "supabase-js works unchanged" is easy; keeping it true is a test-suite problem. 120 tests run the real @supabase/supabase-js against the backend — REST via in-process fetch, realtime over actual WebSockets, zero mocks — and pass on both the wasm and native engines. Overall coverage lands around 80% of the supabase-js SDK surface, and roughly 90% of what a typical CRUD + auth + storage + realtime app actually calls.
The numbers
The footprint benchmark (reproducible via bench/footprint.ts in the repo) against the same workload — boot, 1,000 inserts, 1,000 filtered reads:
| tinbase (single binary) | Supabase local | PocketBase | |
|---|---|---|---|
| Memory at boot | 49 MB | 1,441 MB | 15 MB |
| Memory after workload | 66 MB | 1,626 MB | 24 MB |
| Install size | 92 MB | 2,291 MB | 30 MB |
| Processes | 2 | 12 containers + Docker | 1 |
| Database | real Postgres 17 + RLS | Postgres 17 | SQLite |
The honest read: versus Supabase local, ~16–24x less memory and a ~2s boot instead of a minute, with the same SDK and APIs. Versus PocketBase, roughly 2.7x the RAM — but running real Postgres with RLS behind Supabase's exact wire APIs, so code and migrations move to hosted Supabase unchanged.
What it looks like in practice
npx tinbase start
# API URL: http://127.0.0.1:54321
# anon key: eyJ...
import { createClient } from '@supabase/supabase-js'
const supabase = createClient('http://127.0.0.1:54321', ANON_KEY)
// everything below this line is unchanged app code
await supabase.auth.signUp({ email: 'me@example.com', password: 'secret123' })
const { data } = await supabase.from('todos').select('*, author:users(name)').eq('done', false)
Or skip the terminal and run the whole backend in a tab: [browser demo link]
Honest limits
tinbase is alpha (currently v0.6.x). It's built for local development, prototyping, and embedded/browser use — for production, hosted Supabase remains the recommendation, and a tinbase project migrates there unchanged, which is the point of protocol compatibility. Known gaps are documented in the README: no MFA/SSO/phone auth yet, no resumable (TUS) uploads or image transformations, no pgvector, and the engines currently serialize writes over a single connection — fine for dev tools and small apps, not high-concurrency production. The issue tracker is open and good first issues are labeled.
Links
- GitHub (MIT): https://github.com/tinbase/tinbase
- Live on Product Hunt today: https://www.producthunt.com/products/tinbase
- Website: https://tinbase.dev
- Built by the team behind RapidNative
Questions about the protocol reimplementation, the engine adapters, or the fetch-layer trick are welcome in the comments — the team is answering everything today.
Top comments (0)