Three ways to build with Supabase
I had one week to learn a zero-knowledge proof language I’d never touched before. Nights only. The hackathon had a hard deadline, and I needed every hour for the cryptography. So I didn’t build a backend. I opened Supabase, created a project, and moved on.
That’s not laziness. That’s the whole point. Authentication with email OTP was ready in minutes. A user signs up, gets a verification code, confirms it, and they’re in the app. The part that used to eat entire weekends, ready in minutes.
That project won the hackathon and the backend wasn’t even part of the pitch. It was just infrastructure that didn’t need explaining.
We’ve built three apps with Supabase, each using it completely differently. One treats it as a pure database where the commitments table is open to everyone. Another runs the full platform: auth, storage, server-side functions, row-level security. The third fights against almost every default Supabase ships with, because browser extensions don’t follow the same rules as web apps.
Most people treat Supabase like a product you adopt wholesale. A Firebase alternative, a backend-as-a-service, the full package. That framing misses what makes it actually powerful: you can peel layers on or off depending on what you’re building. And once you see it that way, you stop asking “should I use Supabase?” and start asking “which parts do I need?”
Supabase is layers, not a monolith
Supabase isn’t one product. It’s a Postgres database with optional services you can turn on independently.
| Layer | Service | What it does |
|---|---|---|
| Auth | GoTrue | Email/password, OTP, OAuth, magic links |
| API | PostgREST | Auto-generated REST API from your schema |
| Realtime | Realtime server | WebSocket subscriptions on table changes |
| Storage | S3-compatible | File uploads with access policies |
| Functions | Edge Functions | Server-side logic on Deno |
| Vectors | pgvector | Embeddings and similarity search |
Most tutorials treat this as a single package. “Set up Supabase” means turning everything on. But when you’re shipping real products, the question isn’t “how do I use Supabase.” It’s “which layers does this project actually need?”
Our three apps gave three completely different answers.
Architecture 1: Just the database
One of our projects is a privacy-first verification tool. Users submit sensitive numeric data, and others can verify that data falls within certain ranges without ever seeing the actual numbers. The cryptography is built on zero-knowledge proofs. This is the one that won the hackathon.
The app does have authentication. Users log in, commitments are tied to their accounts, and they can list their verified proofs with the last validation date. But the Supabase layer handling all that is just Auth and basic row storage. The interesting part is what happens with the cryptographic data itself.
The commitments table is open to everyone. Not by accident. The whole point of zero-knowledge proofs is that the data proves itself. You don’t need to hide the hashes. You don’t need RLS on them. Anyone can read every commitment in the table, and it tells them nothing without the secret key.
That sounds reckless until you understand what’s actually stored.
The split-secret pattern
Think of it like a lock and key. The database holds the lock: a cryptographic hash that proves someone committed to a specific value. But the key, the salt that makes the hash verifiable, never touches the server. It stays in the user’s browser.
flowchart LR
A["Generate ZK Commitment"] --> B["Hash (public)"]
A --> C["Salt (secret)"]
B --> D[("Supabase\nentries table")]
C --> E[("Browser\nlocalStorage")]
D -. "UUID links both" .-> E
Supabase gives back a UUID on insert. That UUID becomes the bridge between the public hash in the database and the private salt in localStorage. At verification time, both halves reunite to feed the ZK circuit.
The database being “open” isn’t a vulnerability. It’s the design. Anyone can read every hash in the table, and it tells them nothing. Without the salt, a commitment hash is just noise.
The tradeoff is real, though. If a user clears their browser data or switches devices without exporting their keys, the salts are gone. The proofs still exist in Supabase with the last validation date visible, but they can’t be verified again. The app detects the missing salt and shows this clearly instead of failing silently.
The commitment data is public by design. The auth layer handles user accounts and proof history, but the cryptographic guarantees come from the math, not from access control.
Architecture 2: The full backend
Another project is an admin platform for a professional services operation. Staff manage submissions, approve new members, handle scheduling requests, and upload files. The public-facing side has forms that anyone can fill out without logging in.
This one uses almost everything Supabase offers.
Auth: OTP with a twist
Authentication runs on email OTP. No passwords. Simple enough, except the admin interface is built with React Admin and the ra-supabase adapter. Both assume email and password login. That’s their default flow.
We didn’t fork either library. We bypassed React Admin’s login method entirely. The OTP flow runs through separate components, with the email passed between pages via sessionStorage. The auth provider never handles credentials. It just checks if a session already exists.
Once Supabase creates a session, the JWT attaches to every request automatically. React Admin never knows the difference. The data provider works without changes because it doesn’t care how you authenticated, only that you did.
sequenceDiagram
participant S as Staff
participant OTP as OTP Pages
participant SB as Supabase Auth
participant RA as React Admin
S->>OTP: Enter email
OTP->>SB: signInWithOtp({ email })
SB-->>S: Code via email
S->>OTP: Enter code
OTP->>SB: verifyOtp({ email, token })
SB-->>OTP: Session active
OTP->>RA: Redirect to /admin
Note over RA: checkAuth() finds session ✓
RPC: Atomic workflows in one transaction
The approval flow is the most interesting piece. When staff approve a submission, three things need to happen atomically: mark the record as accepted, create an auth account for the new member, and insert their profile. If any step fails, none should persist.
A client-side chain of API calls can’t guarantee that. A single Postgres function can.
flowchart TD
A["Client: supabase.rpc('approve_record', { id })"] --> B["SECURITY DEFINER Function"]
B --> C["1. Mark submission accepted"]
C --> D["2. Create auth.users entry\n(ON CONFLICT → update)"]
D --> E["3. Insert member profile"]
B --> F["All succeed or all rollback"]
style B fill:#3ecf8e22,stroke:#3ecf8e
style F fill:#3ecf8e22,stroke:#3ecf8e
SECURITY DEFINER is key. The client can’t write to auth.users directly (the anon key doesn’t have permission). This function runs with the table owner’s privileges, so it can create auth accounts as part of the workflow. One RPC call from the client, one transaction in Postgres.
File uploads go to dedicated storage buckets with public URL resolution. Every query spells out its columns instead of select("*"), keeping types tight and avoiding accidental exposure when columns get added.
This project treats Supabase as a complete backend replacement. No Express server, no API routes, no custom auth middleware. Just Postgres, policies, and functions.
Architecture 3: Auth against the grain
The third project is a browser extension for organizing and syncing collections across devices. Think bookmarks, but structured into groups with cross-device sync.
Browser extensions break every assumption Supabase makes about how auth works.
Supabase’s JS client expects localStorage in a single origin. Extensions don’t have that. A popup, a background script, and a content script are three separate JavaScript contexts. They don’t share storage. They barely share anything.
So the Supabase client is initialized with persistSession: false. The extension manages tokens in the browser’s extension storage API and reinjects them with supabase.auth.setSession() before every sync cycle.
The auth handoff
The extension has a login button, but email OTP creates a UX problem: by the time you open the email and copy the code, the popup is already closed. Opening a new tab or pinning the popup both felt wrong. So we redirect to a landing page for login, which doubles as a way to promote the product. After the user verifies their email OTP on the landing page, the session tokens travel to the extension via cross-origin messaging:
sequenceDiagram
participant U as User
participant W as Landing Page
participant SB as Supabase Auth
participant EX as Extension
U->>W: Enter email + OTP code
W->>SB: verifyOtp()
SB-->>W: Session tokens
W->>EX: chrome.runtime.sendMessage(extensionId, session)
EX->>EX: Store in extension storage
EX-->>W: { success: true }
Note over EX: Ready to sync
Two apps, one auth flow. The web page authenticates, the extension receives.
Same concerns, different answers
Every backend answers the same questions. Our three projects answer all of them differently.
| Concern | Verification Tool | Admin Platform | Browser Extension |
|---|---|---|---|
| Auth | Supabase Auth for accounts; commitments are public by design | OTP via Supabase Auth, adapted for React Admin | OTP with persistSession: false, cross-app handoff |
| Data access | Service class with { success, data, error } tuples | ra-supabase data provider with PostgREST filters | Direct queries in a sync service on a timer |
| Security model | Open database (ZK commitments are safe by design) | RLS policies (database-level) | Application logic (soft deletes, timestamps) |
| Type safety | Hand-written | Generated via supabase gen types typescript | Generated from localStorage schema |
The security row is the most interesting. One project trusts the database to enforce access. Another trusts cryptography and leaves the database wide open. The third trusts application code. Same tool, three fundamentally different trust models.
Pick your layers
After building three apps with Supabase, the biggest lesson isn’t about any specific feature. It’s that Supabase is Postgres first. The dashboard, the SDK, the auth, the storage: those are conveniences built on top of a Postgres database. And the things that mattered most across all three projects were Postgres things. RLS policies. PL/pgSQL functions. Transaction guarantees. ON CONFLICT clauses.
The JS SDK is great for speed. The dashboard is the friendliest database interface I’ve used. But when something complex needs to happen, you write SQL. And it works, because underneath everything, it’s just Postgres. You can enable dozens of Postgres extensions, from PostGIS to pg_cron, and install SQL-based ones directly. Supabase makes the full power of Postgres available in the cloud without taking any of it away.
One more thing worth mentioning: Supabase ships official agent skills and an MCP server for AI coding tools. We use the MCP so the agent can write and apply RLS policies directly to the project. No more copy-pasting SQL between the dashboard and the codebase. It’s one of those tools that makes the whole “pick your layers” approach even faster, because your agent already knows how each layer works.
Before your next project, try this: list the Supabase layers (Auth, PostgREST, Realtime, Storage, Edge Functions) and cross out the ones you don’t need. Whatever’s left is your architecture. You might end up using the full platform. You might end up with nothing but a database and a REST API. Both are valid. Both ship.