Dev Encyclopedia
ArticlesToolsContactAbout

Get notified when new content drops

No spam. Just new articles, tools, and updates straight to your inbox.

Dev Encyclopedia

A reference for builders

Dev.to
Discord
WhatsApp Channel
daily.dev
Hashnode
X

Content

  • Articles
  • Tools
  • About
  • Contact

Connect

  • support@devencyclopedia.com
  • RSS Feed

Legal

  • Privacy Policy
  • Terms of Service
  • Disclaimer

© 2026 Dev Encyclopedia

Back to top ↑
  1. Home
  2. /Blog
  3. /Supabase Tutorial: The Open Source Firebase Alternative
backend21 min read

Supabase Tutorial: The Open Source Firebase Alternative

A beginner Supabase tutorial: build a full-stack app on the open source Firebase alternative with React and Node.js code, auth, RLS, storage, and pricing.

Zeeshan Tofiq
Zeeshan Tofiq
September 24, 2026
On this page

On this page

  • What Is Supabase?
  • Supabase vs Firebase vs Plain PostgreSQL
  • Setting Up Your First Supabase Project
  • Connecting Supabase to React
  • Connecting Supabase to Node.js
  • Supabase Auth and Row Level Security
  • Storage: Uploading and Serving Files
  • Edge Functions
  • Supabase Pricing: What the Free Tier Actually Covers
  • Real-World Use Cases (and When to Skip Supabase)
  • Pre-Launch Checklist
  • Frequently Asked Questions

What Is Supabase?

Supabase is a hosted backend built on top of a real PostgreSQL database. You create a project, and within about two minutes you have a Postgres instance, an auto-generated REST API over every table, an authentication service, file storage, serverless functions, and a dashboard to manage all of it. The pitch is that you skip writing the boring 60% of a backend and go straight to product code.

It is usually described as "the open source Firebase alternative", and that comparison is useful as a starting point but misleading if you stop there. Firebase gives you a proprietary document store. Supabase gives you Postgres, which means SQL, joins, constraints, transactions, views, triggers, and every extension the Postgres ecosystem has built over three decades. If you already know relational databases, most of what you know transfers directly.

The parts you get out of the box are worth listing explicitly, because each one is a service you would otherwise have to build, host, and secure yourself:

  • PostgreSQL database with a full SQL editor, a visual table editor, and migrations you can keep in version control.
  • Auto-generated APIs: a REST API (via PostgREST) and a GraphQL endpoint derived from your schema, updated the moment you add a column.
  • Auth: email/password, magic links, phone OTP, and social OAuth providers, with user records living in your own database.
  • Realtime: subscribe to INSERT, UPDATE, and DELETE events on a table over a WebSocket, plus presence and broadcast channels.
  • Storage: an S3-compatible object store with access rules written the same way as your database policies.
  • Edge Functions: Deno-based serverless functions deployed close to your users.

It is also genuinely open source. The core platform is on GitHub and you can self-host the whole stack with Docker, which means the "what if they shut down or raise prices" question has a real answer rather than a migration nightmare. Your data is in Postgres, so worst case you take a pg_dump and run it anywhere.

Supabase project dashboard showing the left sidebar with Table Editor, SQL Editor, Authentication, Storage and Edge Functions, and the main panel displaying a todos table with id, task, is_complete and user_id columns
The Supabase dashboard. The table editor on the right is a live view of a real Postgres table, not an abstraction over one.

Supabase vs Firebase vs Plain PostgreSQL

Two comparisons matter when you are deciding whether to adopt Supabase. The first is against Firebase, the incumbent backend-as-a-service. The second is against just running Postgres yourself, which is what many teams do by default.

FirebaseSupabase
Data modelFirestore document store, denormalised, no joinsPostgreSQL, relational, joins and foreign keys
Query languageProprietary SDK query builder, limited compound queriesFull SQL, plus a JS query builder that maps onto it
AuthorisationFirestore Security Rules, a separate rules languagePostgres Row Level Security, written in SQL alongside the schema
LicensingProprietary, Google-hosted onlyApache 2.0 / MIT core, self-hostable with Docker
RealtimeFirst-class, the product was built around itAvailable per table, opt-in, built on Postgres logical replication
Cold-start migration pathExporting to another platform means reshaping documentspg_dump restores anywhere Postgres runs

The honest summary: Firebase is still stronger if your app is mobile-first, offline-first, and mostly reads and writes small documents. Its offline sync is mature in a way Supabase's is not. Supabase wins the moment your data is genuinely relational, which for most CRUD web apps happens by week three.

The second comparison is subtler. If you already run Postgres on RDS or Neon, what does Supabase add? Everything above the database. You are not paying for Postgres, you are paying to not build auth, file uploads, WebSocket infrastructure, and an API layer.

What sits between a raw Postgres instance and a Supabase project.
LayerRaw PostgreSQLSupabase
DatabaseYou provision, patch, and back it upManaged instance, daily backups on paid plans
APIYou write every endpointREST and GraphQL generated from the schema
AuthPassport, Auth0, or hand-rolled sessionsBuilt in, users stored in `auth.users` in your own DB
AuthorisationApplication-layer checks in your handlersRLS policies enforced by the database itself
File storageS3 plus signing logic you maintainStorage API with policy-based access control
RealtimeSocket server, pub/sub, scaling, reconnection`postgres_changes` subscriptions over a managed WebSocket

Because it is ordinary Postgres underneath, everything you know about schema design still applies, and so does everything you know about its quirks. If you are arriving from MySQL, the differences in types, quoting, and indexing are worth reading first: our guide to PostgreSQL for MySQL developers covers the ones that cause real bugs.

Side-by-side comparison diagram of Firebase and Supabase architectures, with Firebase showing a Firestore document collection and Security Rules layer, and Supabase showing a PostgreSQL table with Row Level Security policies feeding a PostgREST API
The core structural difference: documents plus a rules language on one side, relational tables plus RLS policies on the other.

Setting Up Your First Supabase Project

Creating a project takes a few minutes, most of which is the database provisioning while you wait. The important part is not the clicks, it is knowing which two keys you get and which one is safe to ship to a browser.

  1. 1

    Create an Account and a Project

    Sign up at supabase.com with GitHub or email. Create an organisation, then a project inside it. You will be asked for a project name, a database password, and a region.

    Pick the region closest to your users, not to you. This is the one setting you cannot change later without migrating the project, and it determines the latency of every query your app makes.

    ⚠ Save the database password now

    The password you set at creation is the Postgres superuser-level password used for direct connections and migrations. Supabase does not show it again. Put it in your password manager before you click create.

  2. 2

    Find Your API Keys

    Go to Project Settings, then API. You will see a project URL and two keys. The distinction between them is the single most important security concept in Supabase.

    KeyWhere it belongsWhat it can do
    `anon` (publishable)Browser, mobile app, any client bundleOnly what your RLS policies allow for anonymous or logged-in users
    `service_role` (secret)Server only, never in a client bundleBypasses every RLS policy, full read and write on all tables

    🚫 The service_role key bypasses Row Level Security entirely

    If it ever reaches a browser bundle, anyone can read and write every row in your database. Keep it in server-side environment variables only, and never prefix it with NEXT_PUBLIC_ or VITE_.

    How you load those variables matters as much as which one you use. Next.js in particular has rules about which prefixes get inlined into the client bundle, and getting them wrong is how secrets leak. Our guide to Next.js environment variables covers the exact boundary.

  3. 3

    Create Your First Table

    You can use the visual table editor, but writing SQL in the SQL Editor is faster and gives you something you can commit to your repository. Here is a todos table with the ownership column you will need for RLS later:

    sql — supabase/migrations/0001_todos.sql
    create table public.todos (
      id          bigint generated always as identity primary key,
      user_id     uuid not null references auth.users (id) on delete cascade,
      task        text not null check (char_length(task) > 0),
      is_complete boolean not null default false,
      created_at  timestamptz not null default now()
    );
    
    -- Every RLS policy below filters on user_id, so index it.
    create index todos_user_id_idx on public.todos (user_id);

    Note references auth.users (id). Supabase stores authenticated users in an auth schema inside the same database, so your application tables can have real foreign keys to them. This is the thing you cannot do with an external auth provider, and it is a genuine advantage: a deleted user cascades to their rows automatically.

  4. 4

    Install the CLI for Local Development

    bash
    npm install -g supabase
    supabase login
    supabase init
    supabase link --project-ref your-project-ref
    supabase start

    supabase start runs the entire stack locally in Docker: Postgres, the API gateway, auth, storage, and a local dashboard on port 54323. Develop against that, then push schema changes up with supabase db push. Working this way means your schema lives in migration files under version control instead of only in the hosted dashboard, which is the difference between a project two people can work on and one that only you can.

Connecting Supabase to React

The React integration is a single package and a single client instance. There is no provider you have to wrap your app in, and no code generation step.

bash
npm install @supabase/supabase-js

Create the client once in its own module and import it everywhere. Creating a new client per component leaks WebSocket connections and breaks session persistence, which is the most common beginner mistake with this library.

ts — src/lib/supabase.ts
import { createClient } from "@supabase/supabase-js";

const url = import.meta.env.VITE_SUPABASE_URL;
const anonKey = import.meta.env.VITE_SUPABASE_ANON_KEY;

if (!url || !anonKey) {
  throw new Error("Missing Supabase environment variables");
}

// One client for the whole app: it owns the auth session and the realtime socket.
export const supabase = createClient(url, anonKey);

Now the CRUD operations. The query builder is a thin, chainable wrapper over the REST API, and it reads close enough to SQL that you can usually guess the method you need.

tsx — src/components/TodoList.tsx
import { useEffect, useState } from "react";
import { supabase } from "../lib/supabase";

type Todo = {
  id: number;
  task: string;
  is_complete: boolean;
};

export function TodoList() {
  const [todos, setTodos] = useState<Todo[]>([]);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    async function load() {
      const { data, error } = await supabase
        .from("todos")
        .select("id, task, is_complete")
        .order("created_at", { ascending: false })
        .limit(50);

      if (error) setError(error.message);
      else setTodos(data);
    }
    load();
  }, []);

  async function addTodo(task: string) {
    const { data, error } = await supabase
      .from("todos")
      .insert({ task })
      .select()
      .single();

    if (!error && data) setTodos((prev) => [data, ...prev]);
  }

  async function toggle(todo: Todo) {
    await supabase
      .from("todos")
      .update({ is_complete: !todo.is_complete })
      .eq("id", todo.id);
  }

  async function remove(id: number) {
    await supabase.from("todos").delete().eq("id", id);
  }

  if (error) return <p role="alert">{error}</p>;

  return (
    <ul>
      {todos.map((todo) => (
        <li key={todo.id}>
          <button onClick={() => toggle(todo)}>
            {todo.is_complete ? "Done" : "Open"}
          </button>
          {todo.task}
          <button onClick={() => remove(todo.id)}>Delete</button>
        </li>
      ))}
    </ul>
  );
}

💡 The client never throws

Every Supabase call resolves to { data, error } instead of rejecting. A try/catch around an await will not catch a failed query. Check error on every call, or you will silently render an empty list when a policy blocks the read.

Two details in that insert are worth noticing. There is no user_id in the payload, because a database default of auth.uid() fills it in, and an RLS policy would reject a row claiming another user's id anyway. And .select().single() after the insert returns the created row including its generated id, saving a round trip.

Realtime is the feature people come to Supabase for, and it is about ten lines. Subscribe to a channel, filter it to the table and events you care about, and unsubscribe on unmount:

tsx — src/hooks/useRealtimeTodos.ts
useEffect(() => {
  const channel = supabase
    .channel("todos-changes")
    .on(
      "postgres_changes",
      { event: "*", schema: "public", table: "todos" },
      (payload) => {
        if (payload.eventType === "INSERT") {
          setTodos((prev) => [payload.new as Todo, ...prev]);
        }
        if (payload.eventType === "DELETE") {
          setTodos((prev) => prev.filter((t) => t.id !== payload.old.id));
        }
      },
    )
    .subscribe();

  // Without this, a remount opens a second socket and events fire twice.
  return () => {
    supabase.removeChannel(channel);
  };
}, []);

Realtime is off by default per table. Enable it in the dashboard under Database, Replication, or by adding the table to the supabase_realtime publication in SQL. If your subscription connects but no events arrive, that is almost always why.

Connecting Supabase to Node.js

On the server you use the same package but a different key and different assumptions. The service_role key bypasses RLS, so the database will not protect you here. Your own code is the only thing standing between a request and every row in the table.

ts — server/supabaseAdmin.ts
import { createClient } from "@supabase/supabase-js";

// service_role bypasses RLS. This module must never be imported by client code.
export const supabaseAdmin = createClient(
  process.env.SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!,
  {
    auth: {
      // No browser here: nothing to persist and no token to auto-refresh.
      persistSession: false,
      autoRefreshToken: false,
    },
  },
);

With that client, server-side CRUD looks identical to the browser version, except that nothing is filtered for you. Admin jobs, cron tasks, and webhook handlers are the legitimate uses:

ts — server/routes/todos.ts
// Nightly cleanup: no user context, so service_role is the right key.
const cutoff = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString();

const { data, error } = await supabaseAdmin
  .from("todos")
  .delete()
  .eq("is_complete", true)
  .lt("created_at", cutoff)
  .select("id");

if (error) throw new Error(`Cleanup failed: ${error.message}`);
console.log(`Removed ${data.length} completed todos`);

The more interesting server pattern is acting as a user rather than as an admin. When a request arrives with a Supabase access token, you can create a scoped client that runs under that user's identity, which means RLS applies exactly as it would in the browser. This is the safest way to write API routes, because a bug in your handler cannot leak another tenant's data:

ts — server/scopedClient.ts
import { createClient } from "@supabase/supabase-js";

export function clientForRequest(accessToken: string) {
  return createClient(
    process.env.SUPABASE_URL!,
    // anon key + the user's token: RLS policies still apply.
    process.env.SUPABASE_ANON_KEY!,
    {
      global: { headers: { Authorization: `Bearer ${accessToken}` } },
      auth: { persistSession: false },
    },
  );
}

// In an Express handler:
app.get("/api/todos", async (req, res) => {
  const token = req.headers.authorization?.replace("Bearer ", "");
  if (!token) return res.status(401).json({ error: "Unauthorised" });

  const supabase = clientForRequest(token);
  const { data, error } = await supabase.from("todos").select("*");

  if (error) return res.status(400).json({ error: error.message });
  res.json(data);
});

Verifying a token before you trust it is one call, supabaseAdmin.auth.getUser(token), which validates the JWT against the auth server and returns the user record. Do that on any route where the identity matters for something other than an RLS filter.

Architecture diagram showing a React browser client and a Node.js server both connecting to a Supabase project, with the browser using the anon key through Row Level Security and the server using the service role key bypassing RLS, both reaching the same PostgreSQL database alongside Auth, Storage and Edge Functions
Two paths into the same database. The browser goes through RLS with the anon key, the server can bypass it with service_role.

Supabase Auth and Row Level Security

Auth and authorisation are two separate things in Supabase, and conflating them is how projects end up with public data. Auth answers who this is. Row Level Security answers what they are allowed to see. Turning on auth without writing RLS policies leaves your tables wide open to anyone holding the anon key, which is everyone who loads your site.

Signing users in is straightforward. Email and password first:

ts
// Sign up
const { data, error } = await supabase.auth.signUp({
  email: "dev@example.com",
  password: "a-long-random-password",
});

// Sign in
await supabase.auth.signInWithPassword({
  email: "dev@example.com",
  password: "a-long-random-password",
});

// Current session, and a listener for changes
const { data: { user } } = await supabase.auth.getUser();

supabase.auth.onAuthStateChange((event, session) => {
  // SIGNED_IN, SIGNED_OUT, TOKEN_REFRESHED, USER_UPDATED
  setSession(session);
});

await supabase.auth.signOut();

Social login is one method plus some dashboard configuration. Enable the provider under Authentication, Providers, paste in the OAuth client id and secret from GitHub or Google, and add your callback URL to the provider's allowed list:

ts
await supabase.auth.signInWithOAuth({
  provider: "github",
  options: { redirectTo: "https://yourapp.com/auth/callback" },
});

Now the part that actually secures your data. RLS is a Postgres feature, not a Supabase one: you enable it per table, then write policies as SQL expressions that the database evaluates on every row of every query. Supabase exposes the authenticated user's id to those expressions as auth.uid().

sql — supabase/migrations/0002_todos_rls.sql
alter table public.todos enable row level security;

-- Default the owner column so clients never send user_id themselves.
alter table public.todos
  alter column user_id set default auth.uid();

create policy "Users read their own todos"
  on public.todos for select
  using (auth.uid() = user_id);

create policy "Users create todos for themselves"
  on public.todos for insert
  with check (auth.uid() = user_id);

create policy "Users update their own todos"
  on public.todos for update
  using (auth.uid() = user_id)
  with check (auth.uid() = user_id);

create policy "Users delete their own todos"
  on public.todos for delete
  using (auth.uid() = user_id);

using controls which existing rows a statement can see or touch. with check controls what the resulting rows are allowed to look like. Update needs both: without the with check clause, a user could reassign their row to someone else's user_id and hand over their own data.

Once these policies exist, the same select("*") from the browser returns only that user's rows, enforced by Postgres rather than by your application code. A forgotten WHERE clause in a query stops being a data breach.

⚠ RLS is off by default on tables you create in SQL

Tables made through the dashboard's table editor get RLS enabled automatically, but a create table you run in the SQL editor does not. Check the Authentication, Policies page: any table listed as "unrestricted" is readable by anyone with your anon key.

Policies are code, and like all code they need tests. A policy that silently returns zero rows and a policy that silently returns everyone's rows both look fine in manual testing against a single account. We covered exactly this in testing PostgreSQL row-level security with Jest, and the approach applies directly to a Supabase project.

Flowchart of a Supabase authenticated request: user signs in, Supabase Auth issues a JWT containing the user id, the client sends the JWT with each query, PostgREST sets the request role and auth.uid(), and Row Level Security policies filter rows before results return
How a JWT becomes a row filter: the user id travels from the token into auth.uid(), which every RLS policy reads.

Storage: Uploading and Serving Files

Storage is an object store organised into buckets, and its access control uses the same RLS mechanism as your tables. A bucket is either public, meaning anyone with the URL can fetch an object, or private, meaning access goes through policies and signed URLs.

ts
// Upload. upsert: false means a duplicate path errors instead of overwriting.
const { data, error } = await supabase.storage
  .from("avatars")
  .upload(`${user.id}/profile.png`, file, {
    cacheControl: "3600",
    upsert: false,
  });

// Public bucket: a permanent, unauthenticated URL.
const { data: pub } = supabase.storage
  .from("avatars")
  .getPublicUrl(`${user.id}/profile.png`);

// Private bucket: a URL that expires, here after 60 seconds.
const { data: signed } = await supabase.storage
  .from("invoices")
  .createSignedUrl(`${user.id}/march.pdf`, 60);

Prefixing every object path with the user's id is the convention that makes storage policies simple, because a policy can compare the first path segment against auth.uid() and be done. On paid plans, image transformations let you request a resized version by adding a transform option rather than generating and storing thumbnails yourself, which removes a whole background job from most apps.

Edge Functions

Edge Functions are Deno-based serverless functions deployed to Supabase's edge network. They exist for the work that cannot happen in the browser: calling a third-party API with a secret key, handling a Stripe webhook, or running logic that must not be tampered with.

bash
supabase functions new charge-customer
supabase functions deploy charge-customer
supabase secrets set STRIPE_SECRET_KEY=sk_live_xxx
ts — supabase/functions/charge-customer/index.ts
Deno.serve(async (req) => {
  if (req.method !== "POST") {
    return new Response("Method not allowed", { status: 405 });
  }

  const { amount } = await req.json();
  const key = Deno.env.get("STRIPE_SECRET_KEY");

  const res = await fetch("https://api.stripe.com/v1/payment_intents", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${key}`,
      "Content-Type": "application/x-www-form-urlencoded",
    },
    body: new URLSearchParams({ amount: String(amount), currency: "usd" }),
  });

  return new Response(await res.text(), {
    headers: { "Content-Type": "application/json" },
  });
});

Functions can also be triggered by the database itself. A Database Webhook fires an HTTP request to a function on INSERT, UPDATE, or DELETE, which is how you send a welcome email when a row lands in a profiles table without polling for it.

The runtime is Deno with Web APIs rather than Node built-ins, so the constraints match other edge platforms: no fs, no long-running processes, and a CPU budget per request. If you have written for Cloudflare Workers this will feel familiar, and our Hono REST API on Cloudflare Workers guide covers the same class of limitations in depth.

Supabase Pricing: What the Free Tier Actually Covers

Supabase prices per organisation, not per project, and the plans are simple enough to reason about. The figures below reflect the published plans at the time of writing; verify them on the pricing page before you build a budget, because limits do get revised.

Supabase plan tiers at a glance. Overages on paid plans are billed per unit rather than hard-stopping your project.
FreePro (from $25/mo)Team / Enterprise
Database size500 MB8 GB included, then per GBCustom, dedicated instances
File storage1 GB100 GB includedCustom
Bandwidth5 GB250 GB includedCustom
Monthly active users50,000100,000 includedCustom
BackupsNoneDaily, 7-day retentionPoint-in-time recovery
Log retention1 day7 days28 days and up
Project pausingPaused after ~1 week idleNever pausedNever paused
SupportCommunityEmailSLA, dedicated support

⚠ Free projects pause when idle

A free project with no API activity for about a week is paused and stops responding until you restore it from the dashboard. That is fine for a side project, and fatal for a demo you send to a client and forget about. Anything with real users belongs on Pro.

The practical way to estimate cost: the database size and bandwidth lines are what move first for a content-heavy app, and monthly active users is what moves first for a consumer app. A typical small SaaS with a few thousand users and modest file uploads sits inside the Pro plan's included limits, so $25 per month is the realistic number rather than a starting point that balloons.

The line to watch is bandwidth, because serving images directly from Storage counts against it. Putting a CDN in front of public assets is the cheapest optimisation available, and the general principles in our guide to caching strategies apply directly.

Real-World Use Cases (and When to Skip Supabase)

Supabase is at its best when the backend you would otherwise write is mostly CRUD with authentication attached, which describes a large share of web applications. Here is where it genuinely shortens the build, and what the shape of each app looks like.

Where Supabase fits, and what you actually use from it.
App typeWhat you useWhy it fits
SaaS MVPAuth, Postgres, RLS, Edge FunctionsMulti-tenant isolation is a policy on `org_id` rather than a middleware layer you write and test
Internal tool or admin panelAuth with a single OAuth provider, PostgresRestrict sign-ups to one email domain and let RLS handle roles; no backend service to deploy
Collaborative editor or dashboardRealtime, presence, Postgres`postgres_changes` plus presence channels replace a socket server you would otherwise operate
Mobile app backendAuth, Storage, REST APIOne SDK covers sign-in, uploads, and queries across iOS, Android, and web
Marketplace or booking appPostgres constraints, transactions, RLSReal foreign keys and unique constraints prevent double bookings at the database level
AI or vector search feature`pgvector` extension, Edge FunctionsEmbeddings live in the same database as the rows they describe, so one query joins both

It is worth being equally clear about when Supabase is the wrong tool. Its architecture makes some things harder, and knowing which is what stops you from fighting the platform for a month.

  • Heavy offline-first mobile apps. Firebase's offline sync and conflict resolution are years ahead. Supabase can cache, but it does not solve merge conflicts for you.
  • Complex server-side business logic. If most of your endpoints do orchestration, calculation, and third-party integration rather than reading and writing rows, you are writing a backend anyway. Supabase is then just a good managed Postgres with auth attached, which is still fine but a smaller win.
  • Very high write throughput. Realtime is built on logical replication, and extremely write-heavy tables can push the replication slot harder than you want. Benchmark before you commit.
  • Strict data residency or compliance regimes. Achievable, but usually via self-hosting or an enterprise agreement rather than the standard hosted plans.

A useful rule: if you can describe your feature as tables plus permissions, Supabase will save you weeks. If you can only describe it as a set of processes, reach for a conventional backend and use Supabase as the database underneath it. That hybrid is common and works well, and it pairs naturally with a small service layer of the kind described in our Node.js microservices guide.

Pre-Launch Checklist

Most Supabase incidents come from the same handful of omissions. Work through this before you point real users at a project.

  • RLS is enabled on every table in the public schema, with no table showing as "unrestricted"
  • The service_role key appears only in server-side environment variables, never behind a NEXT_PUBLIC_ or VITE_ prefix
  • Every update policy has a with check clause, not just using
  • Columns used in policy expressions, such as user_id or org_id, are indexed
  • The project is on a paid plan so it cannot be paused for inactivity
  • Schema changes live in supabase/migrations and are applied with the CLI, not typed into the dashboard
  • Auth redirect URLs are restricted to your real domains, so a token cannot be sent to an attacker's callback
  • Storage buckets that hold user data are private, with signed URLs rather than public links
  • Every client call checks error rather than assuming data is populated
  • Realtime is enabled only on the tables that need it, since every enabled table adds replication load

Frequently Asked Questions

What is Supabase in simple terms?

Supabase is a hosted backend built around a PostgreSQL database. Creating a project gives you a Postgres instance plus an auto-generated REST and GraphQL API over your tables, user authentication, file storage, realtime subscriptions, and serverless Edge Functions, all managed from one dashboard.

It is often called the open source Firebase alternative because it covers the same ground, but it stores your data in a standard relational database you can query with SQL and export with pg_dump at any time.

Is Supabase better than Firebase?

Neither is universally better. They optimise for different data shapes.

Choose Firebase whenChoose Supabase when
Data shapeDocuments that are read whole and rarely joinedRelational data with foreign keys and reporting queries
Offline supportOffline-first mobile is a hard requirementUsers are mostly online, or brief caching is enough
Team backgroundThe team has no SQL experienceThe team already writes SQL comfortably
Exit strategyVendor lock-in is acceptableYou want to be able to self-host or migrate

For a typical web app with users, ownership, and relationships between records, Supabase usually costs less effort over the project's life, because expressing those relationships in Firestore means denormalising and maintaining the duplicates by hand.

Is Supabase free, and what happens when I outgrow the free tier?

The free tier is genuinely free with no credit card, and it covers a 500 MB database, 1 GB of file storage, 5 GB of bandwidth, and 50,000 monthly active users. Its real constraint is not the limits but the pausing: a free project with no activity for about a week is suspended until you restore it manually.

Moving to Pro is a plan change rather than a migration, so nothing about your code or data changes. Paid plans bill overages per unit instead of cutting your project off, which means a traffic spike raises a bill rather than taking your app down.

How do I make sure my Supabase database is not publicly readable?

Enable Row Level Security on every table and write policies. The anon key is public by design, shipped in your JavaScript bundle, so a table without RLS is readable by anyone who opens devtools. Tables created through the SQL editor do not have RLS turned on automatically.

sql
-- Find every public table that is not protected.
select tablename, rowsecurity
from pg_tables
where schemaname = 'public'
  and rowsecurity = false;

Any row that query returns is exposed. Fix it with alter table <name> enable row level security; followed by policies for each operation you want to allow. Remember that enabling RLS with no policies denies everything, which is the safe direction to fail.

How do I use Supabase with Next.js Server Components?

Use @supabase/ssr rather than creating a plain browser client. Server Components have no localStorage, so the session has to be read from and written to cookies, and that package handles the cookie plumbing on both the server and the client.

ts — utils/supabase/server.ts
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";

export async function createClient() {
  const cookieStore = await cookies();

  return createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        getAll: () => cookieStore.getAll(),
        setAll: (list) => {
          try {
            list.forEach(({ name, value, options }) =>
              cookieStore.set(name, value, options),
            );
          } catch {
            // Called from a Server Component: middleware refreshes the session instead.
          }
        },
      },
    },
  );
}

Pair it with a middleware that refreshes the session on each request, otherwise tokens expire mid-session and users get logged out unpredictably.

Why is my realtime subscription connecting but not firing events?

Three causes account for nearly all of these, in order of likelihood.

  1. Realtime is not enabled for the table. It is opt-in per table. Enable it under Database, Replication, or add the table to the publication: alter publication supabase_realtime add table public.todos;
  2. RLS is blocking the event. Realtime respects your policies. If the subscribed user cannot select the changed row, they never receive the event, which is correct behaviour that looks like a broken socket.
  3. `DELETE` payloads are missing columns. By default payload.old contains only the primary key. Set alter table public.todos replica identity full; if your handler needs other columns from the deleted row.
Can I self-host Supabase, and should I?

Yes. The stack runs with docker compose from the official repository, and it is the same components the hosted platform uses: Postgres, PostgREST, GoTrue for auth, Realtime, Storage, and Kong as the gateway.

Whether you should is a different question. Self-hosting means you now own Postgres backups, upgrades, TLS certificates, monitoring, and the scaling of six services rather than one. That is a reasonable trade when data residency rules require it or when your infrastructure team already runs Postgres in production. For most teams the $25 per month buys back more engineering time than it costs.

The valuable part is that the option exists. Because the platform is open source and your data sits in standard Postgres, migrating off is a database restore rather than a rewrite, and that materially lowers the risk of adopting it in the first place.

Supabase earns its place by removing work you were never going to enjoy: session handling, upload endpoints, socket infrastructure, and a hand-written CRUD API over tables you already designed. What it leaves you with is Postgres, which is a good thing to be left with.

The one habit that separates a project that goes well from one that goes badly is treating Row Level Security as part of the schema rather than as a step for later. Write the policy in the same migration as the table, index the column it filters on, and test it. Everything else in this guide is easier to fix afterwards.

Written by

Zeeshan Tofiq, Full Stack Developer
Zeeshan Tofiq

Full Stack Developer

Full stack developer with over 6 years of experience building production applications. Writes practical guides on JavaScript, TypeScript, React, Node.js, and cloud infrastructure. Focused on helping developers solve real problems with clean, maintainable code.

All articles by Zeeshan TofiqGitHubLinkedIn

Enjoyed this article?

Get practical dev guides, tool updates, and new articles delivered straight to your inbox. No spam, unsubscribe anytime.

Related Articles

databases

PostgreSQL for MySQL Developers: The Complete Guide (Syntax, Queries, and Key Differences)

Coming from MySQL? Learn PostgreSQL's syntax, data types, and query differences with real examples, so you can start writing Postgres with confidence.

Sep 18, 2026·21 min read
databases

Testing PostgreSQL RLS Policies With Jest (No pgTAP)

Test PostgreSQL row-level security with Jest and node-postgres, no pgTAP required. Catch tenant isolation bugs in CI before a bad grant reaches production.

Aug 21, 2026·14 min read
backend

Hono.js Tutorial: REST API with Zod, JWT & Cloudflare Workers

Step-by-step Hono.js tutorial: routing, middleware, Zod validation, JWT auth, and deployment to Cloudflare Workers and Node.js. Working code throughout.

Jun 15, 2026·14 min read

On this page

  • What Is Supabase?
  • Supabase vs Firebase vs Plain PostgreSQL
  • Setting Up Your First Supabase Project
  • Connecting Supabase to React
  • Connecting Supabase to Node.js
  • Supabase Auth and Row Level Security
  • Storage: Uploading and Serving Files
  • Edge Functions
  • Supabase Pricing: What the Free Tier Actually Covers
  • Real-World Use Cases (and When to Skip Supabase)
  • Pre-Launch Checklist
  • Frequently Asked Questions