What it is
Supabase is an open-source backend-as-a-service built on Postgres. Calling it “an open Firebase” sells it short — under the hood it’s a curated stack of best-in-class Postgres tooling: PostgREST for auto-generated REST APIs, GoTrue for JWT auth, Realtime (Elixir) for live row subscriptions over WebSockets, an S3-compatible Storage API with Postgres-enforced ACLs, Edge Functions (Deno) for serverless logic, and pgvector for embeddings. The whole thing is Apache-2.0 licensed, so you can self-host every part of it.
The pitch is uncomfortable for the rest of the BaaS market: you get a real Postgres database you own (not a proprietary key-value store), with Row Level Security policies you can audit, plus auto-generated APIs, auth, file storage and AI primitives, all of which speak SQL underneath. Migrate to bare Postgres any day; nothing locks you in.
Architecture
Every Supabase project is a curated set of containers around one Postgres instance:
- Postgres — primary data store, latest stable Postgres versions with extensions pre-installed (pgvector, pg_graphql, pg_cron, pgsodium, postgis, etc.).
- PostgREST — reads the Postgres schema and exposes every table as a REST endpoint at
/rest/v1/<table>. - GoTrue — signup/login, email + OAuth + magic link, issues JWTs with a
subclaim of the user UUID. - Realtime — subscribes to Postgres logical replication and broadcasts row INSERT/UPDATE/DELETE events to clients over WebSockets.
- Storage API — S3-compatible object store; bucket policies enforced by Postgres RLS.
- Edge Functions — Deno runtime, deployed globally on the edge.
- Kong — API gateway in front of everything.
The supabase-js client wraps the whole thing. One client, one auth context, every service.
Install
Two installs to know — the CLI for local dev and migrations, the JS SDK for the app code.
Supabase CLI:
# macOS (Homebrew tap)
brew install supabase/tap/supabase
# Node (project-local dev dep)
npm install supabase --save-dev
# Yarn / pnpm
yarn add supabase --dev
pnpm add supabase --save-dev --allow-build=supabase
# Scoop on Windows
scoop bucket add supabase https://github.com/supabase/scoop-bucket.git
scoop install supabase
Bootstrap a project:
npx supabase init # creates supabase/ folder with config.toml, migrations/
npx supabase start # spins up Postgres + Studio + GoTrue + Realtime + Storage on Docker
npx supabase status # prints API URL, anon key, service_role key, JWT secret
JS client:
npm install @supabase/supabase-js
Configuration
Project config lives in supabase/config.toml (generated by supabase init). The values you usually touch:
project_id = "my-app"
[api]
port = 54321
schemas = ["public", "graphql_public"]
max_rows = 1000
[db]
port = 54322
major_version = 15
[auth]
enabled = true
site_url = "http://localhost:3000"
additional_redirect_urls = ["https://my-app.vercel.app"]
jwt_expiry = 3600
[auth.email]
enable_signup = true
double_confirm_changes = true
[storage]
enabled = true
file_size_limit = "50MiB"
[edge_runtime]
enabled = true
policy = "oneshot"
Production credentials live in environment variables, never in repo:
SUPABASE_URL=https://<ref>.supabase.co
SUPABASE_ANON_KEY=eyJ… # safe to ship to client
SUPABASE_SERVICE_ROLE_KEY=eyJ… # server-side ONLY, bypasses RLS
Code examples
Row Level Security — the single most important Supabase concept. RLS is off by default; you enable it per table and write policies as SQL.
-- 1. Create the table
create table profiles (
id uuid primary key,
user_id uuid references auth.users,
avatar_url text,
bio text
);
-- 2. Turn RLS on
alter table profiles enable row level security;
-- 3. SELECT: users see only their own row
create policy "Users can see their own profile."
on profiles for select
using ( (select auth.uid()) = user_id );
-- 4. INSERT: users can create only their own row
create policy "Users can create a profile."
on profiles for insert
to authenticated
with check ( (select auth.uid()) = user_id );
-- 5. UPDATE: users can update only their own row
create policy "Users can update their own profile."
on profiles for update
to authenticated
using ( (select auth.uid()) = user_id )
with check ( (select auth.uid()) = user_id );
supabase-js client — auth + query + realtime in one place:
import { createClient } from '@supabase/supabase-js'
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY
)
// Sign in
const { data, error } = await supabase.auth.signInWithPassword({
email: 'me@example.com',
password: 'hunter2'
})
// Query (RLS automatically scopes to the logged-in user)
const { data: profile } = await supabase
.from('profiles')
.select('id, bio, avatar_url')
.single()
// Live subscription — fires on every INSERT/UPDATE/DELETE
const channel = supabase
.channel('profile-changes')
.on('postgres_changes',
{ event: '*', schema: 'public', table: 'profiles' },
(payload) => console.log('change:', payload))
.subscribe()
Edge Function (Deno, deployed to the edge):
# Scaffold
supabase functions new hello-world
# Writes supabase/functions/hello-world/index.ts
# index.ts — uses the built-in Deno.serve (no deno.land/std import needed)
Deno.serve(async (req) => {
const { name } = await req.json()
return Response.json({ message: `Hello ${name}!` })
})
# Serve locally
supabase functions serve hello-world
# Deploy globally
supabase functions deploy hello-world
pgvector — AI/RAG primitive baked in:
-- Enable the extension
create extension if not exists vector;
-- 1536 dimensions = OpenAI text-embedding-3-small output
create table documents (
id bigserial primary key,
content text,
embedding vector(1536)
);
-- ANN index for fast similarity search
create index on documents
using hnsw (embedding vector_cosine_ops);
-- Top-5 nearest neighbours
select id, content
from documents
order by embedding <=> '[0.1, 0.2, …]'::vector
limit 5;
Auth
GoTrue handles signup, signin and session management. Every authenticated request carries a JWT signed with the project’s shared secret; the JWT’s sub claim is the user’s UUID, which RLS policies read via auth.uid(). The supabase-js client refreshes tokens transparently and persists the session to local storage.
// Sign up — email + password
const { data, error } = await supabase.auth.signUp({
email: 'me@example.com',
password: 'hunter2',
})
// Magic link — no password, emailed sign-in
await supabase.auth.signInWithOtp({ email: 'me@example.com' })
// OAuth — redirects to provider, then back to site_url
await supabase.auth.signInWithOAuth({ provider: 'google' })
// Server-side: get the current user from a request
const { data: { user } } = await supabase.auth.getUser()
What’s new / version
Supabase ships a public “Developer Update” every month — May 2026 was the latest at the time of writing, the 28th tracked release. Recent themes:
- Edge Functions with native Deno 2 and longer execution windows.
- pgvector HNSW indexes are now default-on for new projects — faster and more memory-efficient than the older IVFFlat indexes.
- Supabase AI in the dashboard — natural-language SQL generation that respects your schema.
- Branching — preview databases per pull request, mirroring Vercel-style preview deploys.
Why it matters / where I use it
Supabase is my default backend whenever a project needs more than a static file + a form. The Gentleman4u men’s grooming e-commerce site has a 17-table Postgres schema queued behind Supabase for products, orders, addresses and loyalty. The Areta and Tzahi lead pipelines are simpler — CF Worker + D1 wins there — but the moment a project needs auth + RLS + storage in one coherent surface, Supabase is the path of least regret. The Supabase CLI’s ability to spin up the entire stack on Docker for local dev, including Studio, GoTrue, Realtime and Storage, means I can develop with production-shaped data flows offline.
What I keep coming back to: it’s just Postgres. Every RLS policy is plain SQL I can review, version-control and migrate. Every query is a normal SELECT. When something goes wrong the answer is in pg_stat_statements, not a vendor support ticket. Migrations live as plain SQL files in supabase/migrations/, applied with supabase db push; rollback is just a reverse migration. And the day I outgrow it, I run pg_dump and move on. That escape hatch — the fact that the underlying engine is unmodified Postgres — is what makes the lock-in zero.