What you'll learn
Quick Answer
Supabase is an open-source backend-as-a-service built on PostgreSQL. You get a real SQL database, an auto-generated REST API, authentication, file storage, and realtime subscriptions. The JavaScript client,@supabase/supabase-js, turns method chains likesupabase.from('todos').select()into HTTP calls against that API. Unlike Firebase, the data model is relational and the whole stack is self-hostable.
What Supabase gives you
Supabase bundles the pieces most apps need behind a backend, and every piece is an existing open-source project rather than a proprietary black box:
- Database - a full PostgreSQL instance. Real tables, foreign keys, joins, views, triggers, and SQL.
- Auto-generated API - a tool called PostgREST inspects your schema and exposes every table at
/rest/v1/<table>. There is no endpoint code to write. - Auth - email and password, magic links, and OAuth providers, all issuing standard JWTs.
- Storage - an S3-style file store that uses the same permission rules as your tables.
- Realtime - subscribe to inserts, updates, and deletes over a WebSocket.
You can use the hosted platform or run the entire stack yourself with the Supabase CLI and Docker - the dashboard, the database, and the APIs are all in the open-source repository. That is the core difference from Firebase: no lock-in to one vendor's infrastructure, and your data is standard Postgres you can export and move.
Connecting the client
Install the client:
npm i @supabase/supabase-js
Both values you need - the project URL and the anon key - are in the dashboard under Project Settings, API. Create one client and reuse it across your app rather than calling createClient per request:
import { createClient } from '@supabase/supabase-js';
export const supabase = createClient(
import.meta.env.VITE_SUPABASE_URL,
import.meta.env.VITE_SUPABASE_ANON_KEY
);
The anon key is safe to ship in a browser bundle - it is a public, low-privilege key mapped to a Postgres role called anon, and it is meant to be there. What actually protects your data is Row Level Security, covered below, not hiding the key. The separate service_role key bypasses every security rule and must stay on a server, never in client code or a repo.
The client object exposes .from() for tables, .auth for users, .storage for files, .rpc() for database functions, and .channel() for realtime. A single await on any query returns a { data, error } object.
Reading and writing data
The query builder is a chain of methods that compiles to a single HTTP request:
// GET /rest/v1/todos?select=id,title,done&done=eq.false&order=id.desc&limit=10
const { data, error } = await supabase
.from('todos')
.select('id, title, done')
.eq('done', false)
.order('id', { ascending: false })
.limit(10);
Every call resolves to a { data, error } object - it does not throw. A permission failure, an unknown column, a constraint violation: all of them come back as error with data set to null. If you wrap a Supabase call in try/catch expecting the catch block to fire, you will miss every one of these. Check error first, every time.
await supabase.from('todos').insert({ title: 'Buy milk', done: false });
await supabase.from('todos').update({ done: true }).eq('id', 1);
await supabase.from('todos').delete().eq('id', 1);
An .insert() given an array inserts many rows in one request. Add .select() after a write to get the affected rows back. .single() unwraps a one-row result into an object instead of an array - but if the query returns zero rows it sets error with code PGRST116. Use .maybeSingle() when "no row" is a valid outcome.
Row Level Security is not optional
PostgREST exposes every table by default. The thing standing between the public anon key and your entire database is Row Level Security (RLS) - a PostgreSQL feature where you attach SQL policies to a table that decide which rows each request may read or change.
Two mistakes are common, in opposite directions.
RLS off. A table created with raw SQL has RLS disabled, so anyone with the anon key - which is sitting in your public bundle - can read and write every row. The dashboard now enables RLS on tables you create through it, but it is easy to lose that when running migrations.
RLS on, no policy. Enabling RLS with no policies denies everything. Your queries suddenly return an empty array with no error, and you go hunting for a bug in client code that is not there. You need an explicit policy:
alter table todos enable row level security;
create policy "users read their own todos"
on todos for select
using ( auth.uid() = user_id );
auth.uid() returns the user ID from the caller's JWT. The rule runs inside Postgres, so it holds no matter how the request arrives - REST, realtime, or a direct SQL connection.
Auth and realtime
Auth is a set of method calls on supabase.auth:
await supabase.auth.signUp({ email, password });
await supabase.auth.signInWithPassword({ email, password });
await supabase.auth.signInWithOAuth({ provider: 'github' });
const { data: { user } } = await supabase.auth.getUser();
signInWithOAuth returns a url to send the browser to; the provider redirects the user back with a session that the client stores and refreshes automatically. To react to login and logout, subscribe:
const { data: { subscription } } =
supabase.auth.onAuthStateChange((event, session) => {
// event is 'SIGNED_IN', 'SIGNED_OUT', 'TOKEN_REFRESHED', ...
});
// later, to stop listening:
subscription.unsubscribe();
Realtime listens to database changes over a WebSocket:
supabase
.channel('todos-changes')
.on('postgres_changes',
{ event: 'INSERT', schema: 'public', table: 'todos' },
(payload) => console.log(payload.new))
.subscribe();
Realtime respects RLS - a client is only pushed rows it is allowed to read - but you must first enable replication for the table in the dashboard.
Supabase vs Firebase: choosing
Both remove the need to run your own backend for a large class of apps. The differences that decide it:
- Data model. Supabase is relational Postgres - joins, foreign keys, transactions, and SQL you already know. Firestore is a document store; relationships and aggregate queries are awkward, and you often duplicate data to work around it.
- Querying. Supabase filters, sorts, and joins on the server. Firestore's queries are deliberately limited to stay fast, and some queries are simply not possible without extra collections or a secondary index service.
- Portability. Supabase is open source and self-hostable; a
pg_dumptakes your data anywhere. Firebase runs only on Google's infrastructure. - Maturity. Firebase has existed far longer, with a bigger ecosystem, first-class mobile SDKs, and a long track record at scale. Supabase is younger.
Pick Supabase when your data is relational, you want SQL, or you care about avoiding lock-in. Pick Firebase when you want the most mature mobile story and your data is genuinely document-shaped.
