How to Use Supabase as a Firebase Alternative: A Complete Step-by-Step Guide
For years, Firebase has been the go‑to backend‑as‑a‑service (BaaS) for developers who want to ship fast without managing servers. Its real‑time database, authentication suite, and cloud functions have powered countless mobile and web applications. However, as projects grow, many developers hit Firebase’s limitations: vendor lock‑in, proprietary query languages, scaling costs, and the inability to run complex relational queries. Enter Supabase – an open‑source BaaS that positions itself as a direct Firebase alternative. Supabase is built on top of PostgreSQL, giving you a mature, ACID‑compliant relational database from day one. It offers real‑time subscriptions, authentication, file storage, and edge functions, all while being fully self‑hostable or managed via the cloud. In this comprehensive tutorial, we will walk through every major feature of Supabase, from setting up your first project to implementing authentication, row‑level security, real‑time data, and file storage. By the end, you’ll understand not only how to use Supabase but also why it may be a better fit for your next project.
Before we dive into the steps, let’s quickly address the elephant in the room: why replace Firebase with something else? Firebase’s Realtime Database and Firestore are NoSQL document stores. While flexible, they force you to denormalise data and think in terms of document hierarchies. Complex joins, aggregations, and transactions become painful. Supabase, by using PostgreSQL, lets you harness the full power of SQL: foreign keys, indexes, views, and advanced functions like full‑text search. Moreover, Supabase’s core is open‑source – you can inspect the code, contribute, and even run your own instance on your own infrastructure if you need to. The skills you learn (SQL, Row‑Level Security, relational modeling) are transferable to any modern backend stack. Let’s start by getting your hands dirty with a real project.

Step 1: Setting Up a Supabase Project and Connecting to PostgreSQL
Your journey begins at supabase.com. Sign up for a free account – you’ll get two projects that include 500 MB of database storage and 2 GB of bandwidth each, more than enough for prototypes and small applications. Once logged in, click “New project” and give it a name (e.g., “my‑supabase‑app”). Choose a secure database password – you’ll need it if you ever want to connect directly via a SQL client. Select a region close to your users, and wait a minute while Supabase spins up a full PostgreSQL instance, completes with an API layer, real‑time engine, and authentication endpoints. After the project is ready, you’ll land on the dashboard. Here, locate the “Project Settings” -> “API” section. You’ll see your anon public key and the service_role secret key (the latter should never be exposed client‑side). The URL (e.g., https://yourproject.supabase.co) is your API endpoint. These credentials are all you need to start interacting with your database from your frontend or backend.
Now, let’s create a simple schema. In Supabase, you can use the SQL Editor to write raw SQL – a huge advantage over Firebase’s constrained console. Navigate to the “SQL Editor” and paste the following script to create a todos table and an accounts table (wil be useful for authentication later):
-- Create accounts table (linked to Supabase auth users)
CREATE TABLE public.accounts (
id uuid NOT NULL PRIMARY KEY,
email text,
created_at timestamptz DEFAULT now(),
updated_at timestamptz DEFAULT now()
);
-- Create todos table
CREATE TABLE public.todos (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id uuid NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE,
title text NOT NULL,
is_complete boolean DEFAULT false,
inserted_at timestamptz DEFAULT now()
);
-- Enable Row Level Security
ALTER TABLE public.todos ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.accounts ENABLE ROW LEVEL SECURITY;
After running this, you have two tables. You’ll also see that Supabase automatically created a table called auth.users where authentication records live. We’ll tie our accounts table to those users via triggers (optional, but useful). For a quick test, go to the “Table Editor” and manually insert a couple of todos. You’ll notice that the UI resembles a traditional database GUI – you can browse, filter, and edit rows. This is a massive improvement over Firebase’s document view, especially when you have relational data. Now that your database and project are ready, let’s move on to authentication.
Step 2: Implementing Authentication (Email/Password and OAuth)
Supabase Authentication is built on top of GoTrue, the same underlying technology used by Netlify Identity. It supports email/password, magic links, phone (SMS), and many social providers (Google, GitHub, Facebook, etc.) with a single API. To activate a provider, go to the dashboard -> “Authentication” -> “Settings” and enable the “Email” provider. For production, you’ll configure the SMTP server to send real emails. For testing, Supabase provides a fake email service that shows confirmation codes in the dashboard logs. Next, to integrate authentication into your code, install the Supabase JavaScript client library:
npm install @supabase/supabase-js
Then initialise the client with your project URL and anon key:
import { createClient } from '@supabase/supabase-js'
const supabase = createClient('https://yourproject.supabase.co', 'your-anon-key')
Now implement a sign‑up form. Use supabase.auth.signUp() and store the user’s email and password. After sign‑up, a user is created in auth.users. However, to mirror our accounts table, we need to insert a row when a new user signs up. The recommended pattern is to use a database trigger inside PostgreSQL. In the SQL Editor, run:
-- Function to copy user data to accounts
CREATE OR REPLACE FUNCTION public.handle_new_user()
RETURNS trigger AS $$
BEGIN
INSERT INTO public.accounts (id, email, created_at, updated_at)
VALUES (NEW.id, NEW.email, now(), now());
RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- Trigger after insert on auth.users
CREATE TRIGGER on_auth_user_created
AFTER INSERT ON auth.users
FOR EACH ROW EXECUTE FUNCTION public.handle_new_user();
Now every new user automatically gets a corresponding row in accounts. Logging in is equally simple: use supabase.auth.signInWithPassword(). The client automatically handles JWT management, refreshes tokens, and stores the session. For social logins, call supabase.auth.signInWithOAuth({ provider: 'github' }). Supabase will redirect the user to GitHub, then back to your app. Your app can listen to auth state changes with supabase.auth.onAuthStateChanged(). This listener fires when the user logs in, logs out, or when the token refreshes – perfect for updating your UI. One critical point: never call authentication methods with the service_role key client‑side. That key bypasses Row‑Level Security and should only be used in server‑side or backend contexts.
Step 3: Enforcing Row‑Level Security (RLS) with Policies
Supabase’s security model is fundamentally different from Firebase. Firebase uses security rules written in a proprietary JSON syntax. Supabase leverages PostgreSQL’s built‑in Row‑Level Security (RLS). With RLS, you write SQL policies that determine which rows a user can see, insert, update, or delete. These policies can reference the authenticated user’s ID (via auth.uid()), roles, or even custom claims. Because policies are SQL, they can be extremely powerful – you can join across tables, use subqueries, and call functions. Let’s create policies for our todos table to ensure users only see their own todos.
First, make sure RLS is enabled on the table (we did that earlier). Then navigate to the “Authentication” -> “Policies” section in the Supabase dashboard, or write SQL directly. I prefer SQL for clarity. Run these policy statements:
-- Allow users to SELECT only their own todos
CREATE POLICY "Users can view their own todos"
ON public.todos FOR SELECT
USING (auth.uid() = user_id);
-- Allow users to INSERT todos with their own user_id
CREATE POLICY "Users can insert their own todos"
ON public.todos FOR INSERT
WITH CHECK (auth.uid() = user_id);
-- Allow users to UPDATE only their own todos
CREATE POLICY "Users can update their own todos"
ON public.todos FOR UPDATE
USING (auth.uid() = user_id);
-- Allow users to DELETE only their own todos
CREATE POLICY "Users can delete their own todos"
ON public.todos FOR DELETE
USING (auth.uid() = user_id);
After creating these policies, even if a malicious attacker obtains your anon key (which is public), they cannot read or modify todos belonging to other users. The database engine rejects any query that doesn’t satisfy the policy. This is a huge advantage over Firebase’s client‑side rules, which can be accidentally misconfigured. In Supabase, you can test your policies by using the “Run SQL” feature in the dashboard with a simulated role (use set role authenticated and set request.jwt.claims...). For our accounts table, you might want a policy that lets users only see their own account row, but other users (like admins) can see all. RLS scales effortlessly with complex roles.
Step 4: Performing CRUD Operations with the Supabase Client
Now that authentication and security are in place, let’s interact with the database from your frontend. The Supabase JavaScript client offers a fluent query builder that translates to SQL under the hood. For basic CRUD, you use supabase.from('todos') followed by .select(), .insert(), .update(), or .delete(). Remember, because RLS is active, the client automatically includes the user’s JWT in every request, and the database enforces the policies. Here’s an example of fetching todos for the currently logged‑in user:
const { data: todos, error } = await supabase
.from('todos')
.select('*')
.order('inserted_at', { ascending: false })
Notice we didn’t add a .eq('user_id', userId) filter – that’s because the RLS policy already restricts the rows to the current user. For inserting a new todo, you don’t need to specify user_id manually if you use the auth.uid() function in a default value or policy. However, a safer approach is to let the frontend send the authenticated user’s ID (obtained from supabase.auth.user().id):
const user = supabase.auth.user()
const { error } = await supabase
.from('todos')
.insert({ title: 'Learn Supabase', user_id: user.id })
The client also supports complex queries: filtering by multiple columns (.eq('is_complete', false)), joins with other tables (via .select('*, accounts(email)')), and even full‑text search (.textSearch('title', 'learn')). For bulk operations, you can pass an array of objects to .insert() or use .upsert(). One key difference from Firebase: Supabase uses HTTP/REST calls by default, not WebSockets, for standard queries. This means you get the reliability of REST with the flexibility of SQL. However, for real‑time, you can switch to subscriptions – covered next.
It’s also worth noting that Supabase provides client libraries for multiple languages: Python, Dart (Flutter), Swift, Kotlin, and more. The APIs are similar across all of them. If you’re building a mobile app with Flutter, you’ll use supabase-query-builder almost identically. The documentation is excellent and the community is growing fast. To give you a quick reference, here’s a table summarising common CRUD methods:
| Operation | Method | Example |
|---|---|---|
| Select rows | supabase.from('table').select('columns') |
supabase.from('todos').select('title, is_complete') |
| Filter | .eq(column, value), .lt(), etc. |
.eq('is_complete', false) |
| Insert | .insert(object | array) |
.insert({ title: 'Task' }) |
| Update | .update(object).eq(column, value) |
.update({ title: 'New' }).eq('id', 1) |
| Delete | .delete().eq(column, value) |
.delete().eq('id', 1) |
| Order | .order(column, { ascending: boolean }) |
.order('created_at', { ascending: false }) |
| Pagination | .range(start, end) |
.range(0, 9) – first 10 rows |
Step 5: Real‑Time Subscriptions (Live Queries)
One of Firebase’s killer features is real‑time data synchronisation via listeners. Supabase replicates this using PostgreSQL’s built‑in logical replication and a WebSocket layer called Realtime (an open‑source engine). You can subscribe to changes on any table: insert, update, delete, or even listen to a specific row. This is perfect for chat apps, collaborative editors, dashboards, or any dynamic UI. To use real‑time, you first need to enable replication on your table in the Supabase dashboard. Go to “Database” -> “Replication” and ensure your table (e.g., todos) is listed. By default, Supabase enables replication for all new tables. Then, in your client code, you can set up a subscription:
const mySubscription = supabase
.channel('public:todos')
.on('postgres_changes',
{ event: '*', schema: 'public', table: 'todos' },
(payload) => {
console.log('Change received!', payload)
// Update your UI state
}
)
.subscribe()
The first argument to supabase.channel is a unique channel name – you can use any string. The on('postgres_changes', ...) listener will fire whenever a row in todos is inserted, updated, or deleted. You can filter by specific events (event: 'INSERT') or by a specific row ID. Under the hood, Supabase uses PostgreSQL’s WAL (Write‑Ahead Log) to capture changes and broadcasts them to all connected clients. The latency is typically under 100ms. One important consideration: real‑time subscriptions respect RLS policies. So if a user is not allowed to see a particular row (because the policy blocks SELECT), they will not receive the change for that row. This is a subtle but powerful security feature. If you need to listen only to changes that affect the current user, combine the subscription with a filter by user_id (though RLS already prevents seeing other rows, the payload still includes the full row – always check RLS for select).
For performance, be careful not to create too many subscriptions. Instead of subscribing to every table, consider using a single channel with multiple filters, or use presence and broadcast features (available in Supabase Realtime client) for collaborative features beyond database changes. The Supabase JavaScript client also supports “presence” (who is online) and “broadcast” (send messages to other clients) – both are out‑of‑the‑box. Real‑time subscriptions are a huge reason to choose Supabase over a plain PostgreSQL backend. They give you Firebase‑like agility without sacrificing the relational model.
Step 6: File Storage (Database as a File System)
Firebase’s Cloud Storage is a separate product integrated with Authentication. Supabase Storage is similarly integrated, built on top of S3‑compatible object storage (default is MinIO, but you can configure S3). You can create buckets, set public/private access, upload files, and generate signed URLs for temporary access. The beauty is that file access permissions are governed by RLS policies – yes, you can write SQL policies for storage! Go to the Supabase dashboard -> “Storage” -> “New bucket”. Create a bucket called avatars and set it to “Public” for testing, or “Private” for secure files. For private buckets, you need to create policies. For example, to allow users to only access their own avatar files, you’d write a policy that checks the file path prefix against auth.uid().
Uploading a file from the client is straightforward:
const { data, error } = await supabase.storage
.from('avatars')
.upload('public/' + user.id + '/' + fileName, file)
You can generate a public URL for public buckets: supabase.storage.from('avatars').getPublicUrl(path). For private buckets, generate a signed URL with an expiration time: supabase.storage.from('private-files').createSignedUrl(path, 60) – the link will only be valid for 60 seconds. This is perfect for secure file delivery without exposing your storage keys. Storage also supports resumable uploads for large files, and you can list files in a folder. The entire storage API is RESTful and can be tested with cURL or Postman. Supabase Storage is a fully functional replacement for Firebase Storage, with the added benefit of SQL‑style permissions.
Step 7: Edge Functions (Serverless Functions)
The final piece of the Firebase alternative puzzle is cloud functions. Firebase Cloud Functions are Node.js functions that run on Google Cloud. Supabase Edge Functions are serverless functions written in TypeScript/JavaScript that run on Deno Deploy (powered by Deno). They are incredibly fast to cold‑start (under 10ms) and can be deployed directly from the Supabase CLI. Edge Functions are ideal for tasks like webhooks, data validation, sending emails, or interacting with third‑party APIs. They integrate with your Supabase project via environment variables and can access your database using the supabase-js client with the service_role key (for admin operations).
To create an Edge Function, install the Supabase CLI: npm install -g supabase. Then run supabase functions new my-function. This generates a TypeScript file with a handler that receives a request object. You can use supabase-js inside the function to read/write the database. Deploy with supabase functions deploy my-function. Each function gets a public URL like https://yourproject.supabase.co/functions/v1/my-function. You can set authentication requirements (like requiring a valid JWT) in the function handler itself. Because Edge Functions run on Deno, they support TypeScript natively, have access to the standard Web APIs, and can import modules from npm or deno.land. This makes them more modern than Firebase Cloud Functions (which are stuck on Node 18). Supabase also provides a “serve” command for local development, so you can test functions before deploying. With Edge Functions, you can move your backend logic out of the client and into the edge, reducing client complexity and improving security.
Tips and Best Practices
Now that you’ve seen the core features, let’s discuss some best practices to keep your Supabase project scalable, secure, and maintainable. First, always use Row‑Level Security. Even if your app is internal and all users are trusted, RLS acts as a safety net to prevent accidental data leaks. Write policies that are as restrictive as possible, and test them thoroughly in the dashboard’s SQL runner. Second, leverage PostgreSQL indexes – add indexes on columns you frequently filter or sort by (e.g., user_id, created_at). Supabase doesn’t create indexes automatically for every foreign key. You can create them from the SQL Editor: CREATE INDEX idx_todos_user_id ON public.todos(user_id);. This will make queries much faster as your data grows. Third, use TypeScript with Supabase. The official package includes type generation tools (run supabase gen types typescript --linked > database.types.ts) that generate TypeScript types from your database schema. This gives you autocomplete and compile‑time error checking for all your queries. Fourth, never expose the service_role key to the client. That key has full access to your database, bypassing RLS. Keep it in your server or Edge Functions. Fifth, monitor your database performance with Supabase’s built‑in logs and the “Health” section. You can see query performance, slow queries, and connection counts. If you plan to self‑host, set up monitoring tools like pg_stat_statements.
Frequently Asked Questions
Q1: How does Supabase compare to Firebase in terms of pricing?
A1: Supabase’s free tier offers 500 MB database, 2 GB bandwidth, and 50,000 monthly active users (for auth) – quite generous compared to Firebase’s Spark plan (which has limited database operations and no Cloud Functions). Paid plans start at $25/month and are based on compute usage and storage. Firebase’s Blaze plan charges per operation, which can be unpredictable. Supabase’s pricing is simpler and often cheaper for moderate usage. However, for extremely high reads, Firebase’s Firestore can be more cost‑effective due to its analytics‑focused pricing. Overall, Supabase is compelling for projects with relational data needs.
Q2: Can I migrate my existing Firebase project to Supabase?
A2: Yes, but it requires effort. You’ll need to redesign your data model from NoSQL to relational SQL. Export Firestore collections to JSON, then import them into PostgreSQL using scripts. For authentication, you cannot directly migrate passwords; users must reset them or you can use Firebase’s custom token flow to generate temporary JWTs. Supabase’s community has several migration guides and tools (like firestore-to-supabase on GitHub). It’s not a one‑click migration, but the long‑term benefits often outweigh the cost.
Q3: Does Supabase support offline persistence like Firebase?
A3: Not natively. Supabase’s client doesn’t come with a built‑in offline database. However, you can combine Supabase with local‑first libraries like RxDB or WatermelonDB for mobile apps. For web, you can cache data in IndexedDB or React Query’s cache. Offline support is a limitation of Supabase compared to Firebase’s Firestore offline mode, but it’s a trade‑off for using a real SQL database.
Q4: How do I handle schema migrations in Supabase?
A4: Since Supabase is PostgreSQL, you can use any migration tool: the built‑in SQL Editor, Flyway, Prisma, or golang‑migrate. Prisma is popular because it provides a type‑safe ORM and can introspect existing tables. Supabase also offers a “Database” section where you can view and alter schemas via a GUI, but for team projects, version‑controlled migration files are essential.
Q5: Can I use Supabase with server‑side rendering (SSR) frameworks like Next.js or Nuxt?
A5: Absolutely. Supabase’s client can be used in Node.js environments. For Next.js, you can use the @supabase/supabase-js package on the server (with service_role key for protected API routes) and on the client (with anon key). Supabase also provides a dedicated Next.js helper library (@supabase/ssr) that handles cookie‑based sessions for RSC (React Server Components). Similarly, for Nuxt 3, there is the @supabase/nuxt module. Supabase is framework‑agnostic and plays well with SSR.
Q6: What about Supabase and real‑time scalability?
A6: The Realtime engine uses PostgreSQL logical replication, which scales vertically (more powerful DB instance). For very high‑frequency updates (thousands per second), you might need to tune PostgreSQL settings or use a dedicated Realtime server. Supabase’s cloud plan offers dedicated Realtime nodes for Pro and Team plans. In self‑hosted scenarios, you can deploy multiple Realtime instances behind a load balancer. It’s less turnkey than Firebase’s globally distributed real‑time, but for most apps, it’s more than adequate.
Conclusion
Supabase has emerged as the most mature and fully featured open‑source alternative to Firebase. In this tutorial, we covered how to set up a project, implement authentication with email and social providers, enforce rock‑solid security with Row‑Level Security, perform CRUD operations with a flexible client, subscribe to real‑time changes, upload files with storage, and extend your backend with Edge Functions. Throughout, you’ve seen that the key differentiator is the use of PostgreSQL – you get a relational database with joins, views, and SQL functions, while still enjoying the convenience of a managed BaaS. The skills you learn with Supabase are transferable to any SQL‑based backend, and the open‑source nature guarantees that you are never locked into a proprietary ecosystem. Whether you’re building a simple blog, a complex SaaS, or a real‑time chat application, Supabase gives you the tools to move fast without compromising on data integrity or security. Start your next project with Supabase – your database will thank you.