Supabase — Database, Auth, Storage, RLS & More

The Backend You Don't Have to Build

You've built a front end. Maybe a to-do app, a blog, a dashboard. It looks great. Then reality hits: you need somewhere to store the data. You need users to log in. You need to save files. And suddenly you're staring down servers, databases, password hashing, and a mountain of backend work you never signed up for.

Supabase exists to hand you all of that, ready-made. You get a real database, authentication, file storage, and realtime updates — without writing or hosting a single line of backend code. You talk to it from your front end with a small JavaScript library, and it handles the rest.

This guide assumes you've never touched Supabase before. We'll go from "what even is this" to building a small Notes app with login and a secure database — step by step. Every code example is plain JavaScript so nothing framework-specific gets in your way.


What Is Supabase?

Supabase is an open-source Backend-as-a-Service (BaaS). That's a fancy way of saying: it gives you the common backend pieces every app needs, as managed services you configure instead of build.

Out of the box you get:

  • Database — a full PostgreSQL (a mature, powerful SQL database)
  • Auth — sign up, log in, sessions, social logins
  • Storage — upload and serve files (images, PDFs, anything)
  • Realtime — live updates pushed to your app when data changes
  • Edge Functions — serverless functions for custom backend logic
  • Auto-generated API — every table instantly becomes a REST and JavaScript API

The key idea: your database is the center of everything. You define your tables, and Supabase automatically gives you a secure API to read and write them, ties authentication into them, and can stream changes in realtime. You spend your time on your app, not your plumbing.

"Backend-as-a-Service" means the backend already exists and is hosted for you. You don't manage servers, you don't run a database process, you don't deploy an API. You configure, then connect.


Supabase vs. Firebase — Which One?

If you've researched at all, you've hit Firebase, Google's BaaS. Supabase is often called "the open-source Firebase alternative," but the most important difference is the database model.

FirebaseSupabase
Database typeNoSQL (Firestore documents)SQL (PostgreSQL, relational)
Data shapeJSON-like documents in collectionsTables with rows & columns
Query powerLimited, denormalizedFull SQL: joins, aggregates, views
OwnerGoogle (closed source)Open source (can self-host)
Pricing modelCharges per operation (reads/writes)Charges for compute & storage
Best when…Unstructured data, mobile-first syncStructured, relational data

The short version: Firebase stores data as loose documents; you fetch a document and get JSON. Supabase gives you a proper relational database with tables that relate to each other — the kind most apps actually need (users have posts, posts have comments, orders have line items).

Firebase can feel marginally simpler on day one. But if your data has relationships (and most data does), Supabase's SQL foundation pays off fast: you can ask complex questions, enforce data integrity, and you're learning transferable SQL skills instead of a proprietary query system. For building real apps, Supabase is usually the better long-term bet.


Setting Up Your First Project

Let's get you a live backend. This takes about three minutes.

  1. Sign up. Go to supabase.com and sign up (GitHub login is fastest).
  2. Create an organization. Supabase groups projects under organizations. Give it any name — your own name is fine.
  3. Create a new project. Click New Project and fill in:
    • Name — e.g. my-notes-app
    • Database Password — this is the password for your Postgres database. Save it somewhere safe — you'll need it for direct database access, and it's shown only once.
    • Region — pick the one geographically closest to your users for lower latency.
    • Pricing plan — the Free tier is generous and perfect for learning.
  4. Wait ~2 minutes. Supabase provisions a real Postgres database for you. When the dashboard lights up, you're live.

Getting Around the Dashboard

The left sidebar is your control center. The pieces you'll use most:

  • Table Editor — a spreadsheet-like view to create and edit tables and data.
  • SQL Editor — write and run raw SQL. Great for anything the visual editor can't do.
  • Authentication — manage users, providers (email, Google, GitHub…), and email templates.
  • Storage — create buckets and upload files.
  • Database — see your schema, roles, functions, and policies (remember this word — RLS lives here).
  • Project Settings → API — where you grab the keys to connect your app.

Understanding the Database

Supabase's database is PostgreSQL, a relational SQL database. If you've never used SQL, here's the entire mental model in three words: tables, columns, rows.

  • A table holds one kind of thing — e.g. a notes table, a users table.
  • Columns define the fields each item has — e.g. id, title, content, created_at. Each column has a type (text, number, boolean, timestamp…).
  • Rows are the actual records — one row per note.

Think of a table as a spreadsheet: columns are the headers across the top, rows are the entries going down.

idtitlecontentcreated_at
1GroceriesMilk, eggs, bread2026-07-03 09:12
2Book ideasA guide to Supabase2026-07-03 10:44

Table Editor vs. SQL Editor

Supabase gives you two ways to create tables — use whichever fits the moment.

The Table Editor (visual): Click New table, name it, and add columns through a form. Great for getting started and quick tweaks. When you create a table this way, Supabase enables Row Level Security by default (more on that shortly — it matters a lot).

The SQL Editor (code): Write CREATE TABLE statements directly. More powerful, repeatable, and shareable. Here's the notes table we'll use, created in SQL:

create table notes (
  id bigint generated always as identity primary key,
  user_id uuid references auth.users not null default auth.uid(),
  title text not null,
  content text,
  created_at timestamptz not null default now()
);

A few things worth understanding in that snippet:

  • id bigint generated always as identity primary key — an auto-incrementing unique ID for each row.
  • user_id uuid references auth.users — links each note to a user in Supabase's built-in auth.users table. This is how we'll later ensure people only see their own notes.
  • default auth.uid() — automatically fills in the current logged-in user's ID on insert, so you don't have to send it manually.
  • timestamptz — a timestamp with time zone; default now() stamps the creation time automatically.

Tip: Prefer the Table Editor while you're learning the shape of your data, then graduate to the SQL Editor as you get comfortable. The SQL you write is standard PostgreSQL — a skill that transfers everywhere.


Connecting Supabase to Your Project

Your database is running. Now let's talk to it from JavaScript.

Step 1 — Install the client library

npm install @supabase/supabase-js

Step 2 — Grab your URL and anon key

In the dashboard, go to Project Settings → API. You need two values:

  • Project URL — e.g. https://abcdefgh.supabase.co
  • anon public key — a long token labeled anon public

These identify your project and are safe to use in front-end code — that's exactly what the "anon" (anonymous) key is designed for. Its power is deliberately limited by Row Level Security (which we'll cover).

⚠️ There's also a service_role key on that page. It bypasses all security rules. Never, ever put it in front-end code or commit it to a public repo. It belongs only in trusted server environments.

Store your keys in environment variables, not hard-coded. With Vite, that means a .env file:

VITE_SUPABASE_URL=https://abcdefgh.supabase.co
VITE_SUPABASE_ANON_KEY=your-anon-key-here

Step 3 — Create the client

Make one shared client and import it everywhere:

// supabaseClient.js
import { createClient } from '@supabase/supabase-js'

const supabaseUrl = import.meta.env.VITE_SUPABASE_URL
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY

export const supabase = createClient(supabaseUrl, supabaseAnonKey)

That supabase object is your gateway to everything: supabase.from(...) for the database, supabase.auth for auth, supabase.storage for files, and supabase.channel(...) for realtime.


CRUD Operations with the JS Client

CRUD = Create, Read, Update, Delete — the four things you do to data. Supabase gives you a clean, chainable API for all of them. Every call returns a { data, error } object, so always check error.

Create (Insert)

const { data, error } = await supabase
  .from('notes')
  .insert({ title: 'Groceries', content: 'Milk, eggs, bread' })
  .select() // return the inserted row(s)

if (error) console.error(error)
else console.log('Inserted:', data)

Notice we didn't send user_id — the default auth.uid() on the column fills it in from the logged-in user automatically.

Read (Select)

// Get all columns
const { data, error } = await supabase
  .from('notes')
  .select('*')

// Get specific columns, filtered and sorted
const { data: recent } = await supabase
  .from('notes')
  .select('id, title, created_at')
  .eq('title', 'Groceries')       // WHERE title = 'Groceries'
  .order('created_at', { ascending: false })
  .limit(10)

Common filter methods: .eq() (equals), .neq() (not equal), .gt() / .lt() (greater/less than), .like() / .ilike() (pattern match), .in() (matches a list). Chain as many as you need.

Update

const { data, error } = await supabase
  .from('notes')
  .update({ content: 'Milk, eggs, bread, coffee' })
  .eq('id', 1)   // ⚠️ WITHOUT a filter, you'd update EVERY row
  .select()

Critical habit: always include a filter (.eq('id', …)) on updates and deletes. An unfiltered .update() or .delete() hits every row in the table.

Delete

const { error } = await supabase
  .from('notes')
  .delete()
  .eq('id', 1)

Authentication (Email & Password)

Almost every app needs users. Supabase Auth handles sign-up, login, sessions, and password resets for you. Email/password is enabled by default.

Sign Up

async function signUp(email, password) {
  const { data, error } = await supabase.auth.signUp({
    email,
    password,
  })
  if (error) return alert(error.message)
  // If email confirmation is ON, the user must click a link before logging in.
  console.log('Signed up:', data.user)
}

By default, Supabase sends a confirmation email. Until the user clicks the link, they can't log in. While developing, you can turn this off under Authentication → Providers → Email → Confirm email so you're not confirming emails constantly. Turn it back on for production.

Log In

async function signIn(email, password) {
  const { data, error } = await supabase.auth.signInWithPassword({
    email,
    password,
  })
  if (error) return alert(error.message)
  console.log('Logged in:', data.user)
}

On success, Supabase stores the session in the browser's local storage automatically and attaches it to every future request — so your database calls now run as that logged-in user.

Log Out

async function signOut() {
  const { error } = await supabase.auth.signOut()
}

Who's Logged In?

// Get the current user (checks the stored session)
const { data: { user } } = await supabase.auth.getUser()

// React to login/logout anywhere in your app
supabase.auth.onAuthStateChange((event, session) => {
  console.log(event, session?.user ?? 'no user')
  // events: 'SIGNED_IN', 'SIGNED_OUT', 'TOKEN_REFRESHED', ...
})

onAuthStateChange is how you keep your UI in sync — show the login form when signed out, show the app when signed in. It fires immediately with the current state and again on every change.


Row Level Security (RLS) — The Trap Everyone Hits

This is the single most important section in this guide. Nearly everyone gets burned by RLS at least once, in one of two opposite ways:

  1. "Why is my data empty?!" — Everything's set up, no errors, but select() returns []. → RLS is on but you have no policy granting access.
  2. "Anyone can read everyone's data!" — → RLS is off, so your anon key can read and write the entire table.

Let's make sure neither happens to you.

What RLS Actually Is

Row Level Security is a built-in PostgreSQL feature. It lets you attach rules — called policies — to a table that decide, per row, whether a given user is allowed to see or change it.

The mental model: think of a policy as a WHERE clause the database secretly bolts onto every query, based on who is asking. If the policy says auth.uid() = user_id, then even a naked select('*') silently becomes "select all rows where the row's user_id equals the current user's ID." Users physically cannot retrieve rows that aren't theirs — the database refuses.

This is huge: your security lives in the database itself, not in your front-end code. Since your anon key is public and anyone can call your API, you cannot trust the client to be well-behaved. RLS is what makes a public API key safe.

The Two-Step Rule

RLS works in two parts, and you need both:

Step 1 — Enable RLS on the table:

alter table notes enable row level security;

(Tables made in the Table Editor have this on already. Tables made via raw SQL do not — you must enable it.)

Step 2 — Add policies that grant access.

Here's the trap: enabling RLS with no policies denies everyone, including logged-in users. The table becomes a locked box. That's the "why is my data empty" mystery — RLS is on, but nothing has been allowed, so every query returns nothing (with no error, which is what makes it so confusing).

Understanding the Roles

Supabase maps every request to one of two Postgres roles based on the session:

  • anon — a visitor who is not logged in (using just the anon key).
  • authenticated — a logged-in user (the anon key plus a valid session).

You target policies at a role with the TO clause, so you can say "anyone can read public posts, but only logged-in users can create them."

USING vs. WITH CHECK

Policies use two kinds of conditions, and knowing the difference clears up most confusion:

  • USING — filters existing rows. "Which rows is this user allowed to see / update / delete?" Applies to SELECT, UPDATE, DELETE.
  • WITH CHECK — validates new or modified rows. "Is the user allowed to write a row that looks like this?" Applies to INSERT and UPDATE.

For an UPDATE you often want both: USING decides which rows they can touch, WITH CHECK makes sure they can't rewrite a row to belong to someone else.

The Four Policies for Our Notes App

Here's the complete, production-shaped set of policies for the notes table — each user can only touch their own notes:

-- 1. SELECT: users can read only their own notes
create policy "Users can view their own notes"
on notes for select
to authenticated
using ( (select auth.uid()) = user_id );

-- 2. INSERT: users can only create notes owned by themselves
create policy "Users can create their own notes"
on notes for insert
to authenticated
with check ( (select auth.uid()) = user_id );

-- 3. UPDATE: users can edit only their own notes, and can't reassign ownership
create policy "Users can update their own notes"
on notes for update
to authenticated
using ( (select auth.uid()) = user_id )
with check ( (select auth.uid()) = user_id );

-- 4. DELETE: users can delete only their own notes
create policy "Users can delete their own notes"
on notes for delete
to authenticated
using ( (select auth.uid()) = user_id );

auth.uid() returns the ID of the user making the request. Compared against the row's user_id column, it locks each row to its owner. Now the same front-end code — supabase.from('notes').select('*') — returns different data for different users, enforced by the database.

A Public-vs-Private Example

Policies stack, so you can mix access levels. Say you have a posts table with an is_public boolean:

-- Anyone (even logged-out visitors) can read public posts
create policy "Public posts are viewable by everyone"
on posts for select
using ( is_public = true );

-- Logged-in users can additionally read their own private posts
create policy "Users can view their own posts"
on posts for select
to authenticated
using ( (select auth.uid()) = author_id );

A visitor sees only public posts; a logged-in author sees public posts plus their own drafts. Multiple SELECT policies are combined with OR.

Performance: Wrap auth.uid() in a SELECT

Notice every policy above uses (select auth.uid()) rather than a bare auth.uid(). This isn't a typo — it's a real optimization. Wrapping it lets Postgres evaluate the function once per query and cache the result, instead of re-running it for every single row. On big tables this is the difference between a snappy query and a painfully slow one.

Two more tips for fast policies:

  • Add an index on any column used in a policy (e.g. user_id): create index on notes (user_id);
  • Always specify the role with TO authenticated / TO anon so Postgres skips the policy entirely for users it doesn't apply to.

Testing Your Policies

You don't have to log in as different people to test. In the dashboard, the SQL Editor and Table Editor let you impersonate a user (look for the role/user dropdown). Run your queries as that user and confirm they see only what they should.

The one rule to never forget: on any table exposed through the API, enable RLS and write policies. If a table has RLS off, your public anon key can do anything to it. If RLS is on with no policies, nobody can do anything. You want on with the right policies.


Storage — File Uploads

Need to store profile pictures, attachments, or PDFs? Supabase Storage handles files, organized into buckets (think of a bucket as a top-level folder).

Create a Bucket

In the dashboard: Storage → New bucket. Name it (e.g. avatars) and choose public or private:

  • Public — files are readable by anyone with the URL (good for profile photos).
  • Private — access is gated by Storage RLS policies (good for private documents).

Upload a File

async function uploadAvatar(file, userId) {
  // Store each user's file under a folder named after their ID
  const filePath = `${userId}/avatar.png`

  const { data, error } = await supabase.storage
    .from('avatars')          // the bucket
    .upload(filePath, file, { upsert: true }) // upsert overwrites if it exists

  if (error) return console.error(error)
  console.log('Uploaded to:', data.path)
}

Get a File's URL

// For a PUBLIC bucket — a permanent URL
const { data } = supabase.storage
  .from('avatars')
  .getPublicUrl('user-123/avatar.png')

console.log(data.publicUrl)

// For a PRIVATE bucket — a temporary signed URL (expires in 60s here)
const { data: signed } = await supabase.storage
  .from('avatars')
  .createSignedUrl('user-123/avatar.png', 60)

Storage Has RLS Too

Files are just rows in a hidden storage.objects table, so the same RLS concept applies. A common pattern: let users upload only into their own folder. The (storage.foldername(name))[1] trick reads the first folder in the path and compares it to the user's ID:

create policy "Users can upload to their own folder"
on storage.objects for insert
to authenticated
with check (
  bucket_id = 'avatars'
  and (storage.foldername(name))[1] = (select auth.uid())::text
);

Realtime Subscriptions

Supabase can push database changes to your app the instant they happen — no polling, no refresh button. Perfect for chats, live dashboards, and collaborative apps.

First, enable realtime for the table (Database → Replication, or via SQL). Then subscribe:

const channel = supabase
  .channel('notes-changes')
  .on(
    'postgres_changes',
    {
      event: '*',              // 'INSERT' | 'UPDATE' | 'DELETE' | '*'
      schema: 'public',
      table: 'notes',
    },
    (payload) => {
      console.log('Change received!', payload)
      // payload.new = the new row, payload.old = the previous row
    }
  )
  .subscribe()

// Later, when you're done (e.g. component unmounts), clean up:
// supabase.removeChannel(channel)

Now whenever anyone inserts, updates, or deletes a note, your callback fires with the details, and you can update the UI live.

RLS applies to realtime too. Users only receive change events for rows their policies allow them to see. Your security rules follow the data everywhere.


Mini Project: A Secure Notes App

Let's tie every concept together into one small, complete app: users sign up, log in, and keep a private list of notes that only they can see. No framework — just the supabase client and plain functions you can wire to buttons.

1. The Database (run once in the SQL Editor)

-- Table
create table notes (
  id bigint generated always as identity primary key,
  user_id uuid references auth.users not null default auth.uid(),
  title text not null,
  content text,
  created_at timestamptz not null default now()
);

-- Speed up policy checks
create index on notes (user_id);

-- Lock it down
alter table notes enable row level security;

create policy "Users can view their own notes"
on notes for select to authenticated
using ( (select auth.uid()) = user_id );

create policy "Users can create their own notes"
on notes for insert to authenticated
with check ( (select auth.uid()) = user_id );

create policy "Users can update their own notes"
on notes for update to authenticated
using ( (select auth.uid()) = user_id )
with check ( (select auth.uid()) = user_id );

create policy "Users can delete their own notes"
on notes for delete to authenticated
using ( (select auth.uid()) = user_id );

2. The Client

// supabaseClient.js
import { createClient } from '@supabase/supabase-js'

export const supabase = createClient(
  import.meta.env.VITE_SUPABASE_URL,
  import.meta.env.VITE_SUPABASE_ANON_KEY
)

3. Auth Functions

import { supabase } from './supabaseClient'

export async function signUp(email, password) {
  const { error } = await supabase.auth.signUp({ email, password })
  if (error) throw error
}

export async function signIn(email, password) {
  const { error } = await supabase.auth.signInWithPassword({ email, password })
  if (error) throw error
}

export async function signOut() {
  await supabase.auth.signOut()
}

4. Notes CRUD

import { supabase } from './supabaseClient'

// Create — user_id is filled in automatically by the column default
export async function addNote(title, content) {
  const { data, error } = await supabase
    .from('notes')
    .insert({ title, content })
    .select()
  if (error) throw error
  return data[0]
}

// Read — RLS ensures this returns ONLY the current user's notes
export async function getNotes() {
  const { data, error } = await supabase
    .from('notes')
    .select('*')
    .order('created_at', { ascending: false })
  if (error) throw error
  return data
}

// Update
export async function editNote(id, updates) {
  const { data, error } = await supabase
    .from('notes')
    .update(updates)
    .eq('id', id)
    .select()
  if (error) throw error
  return data[0]
}

// Delete
export async function removeNote(id) {
  const { error } = await supabase.from('notes').delete().eq('id', id)
  if (error) throw error
}

5. Wire It Together

import { supabase } from './supabaseClient'
import { getNotes, addNote } from './notes'

// Show the right screen depending on auth state
supabase.auth.onAuthStateChange(async (event, session) => {
  if (session) {
    const notes = await getNotes()   // returns only THIS user's notes
    render(notes)
  } else {
    showLoginForm()
  }
})

// Live-update the list when notes change
supabase
  .channel('notes-live')
  .on('postgres_changes',
      { event: '*', schema: 'public', table: 'notes' },
      async () => render(await getNotes()))
  .subscribe()

That's a genuinely secure, realtime, multi-user app — and you never wrote or hosted a backend. Notice how getNotes() has no "where user_id = me" filter in the JavaScript: RLS enforces that in the database, so it's impossible to accidentally leak another user's notes.


Common Mistakes (and Fixes)

1. "My query returns an empty array but there's no error." RLS is enabled with no matching policy. Add the appropriate SELECT policy. This is the #1 issue people hit — remember: RLS on + no policy = everything blocked, silently.

2. "Anyone can read/write my whole table." RLS is disabled. Run alter table <name> enable row level security; and add policies. Never expose a table through the API without RLS.

3. Putting the service_role key in front-end code. That key bypasses all security. Only the anon key belongs in the browser. Keep service_role server-side only, and never commit either key — use environment variables.

4. Forgetting to check error. Every call returns { data, error }. If you ignore error, failures look like "nothing happened." Always check it.

5. Unfiltered updates and deletes. .update() or .delete() without a .eq() filter affects every row. Always filter by id.

6. Can't log in right after signing up. Email confirmation is on by default — the user must click the link first. Turn it off during development, on for production.

7. Slow queries on big tables with RLS. Use (select auth.uid()) instead of bare auth.uid(), add an index on the column used in the policy, and always set the TO role.

8. Realtime callback never fires. Realtime must be enabled for the table (Database → Replication), and remember RLS still filters which events a user receives.


Where to Go Next

You now have the whole foundation: a database you understand, an API to talk to it, authentication, file storage, realtime, and — most importantly — the RLS model that keeps it all secure. That last one is what separates a toy from a real app, so if you internalize one thing from this guide, make it "enable RLS and write policies."

From here, explore:

  • Social logins (Google, GitHub) via signInWithOAuth — a few clicks in the dashboard.
  • Database relationships — foreign keys and Supabase's automatic nested selects (.select('*, comments(*)')).
  • Edge Functions — for custom server-side logic like payments or sending email.
  • The official docs at supabase.com/docs — genuinely approachable, with copy-paste examples.

Build something small, break it, read the errors, fix it. That loop is how this all clicks. Good luck.