A hands-on beginner project. You'll build a small React app where people sign in with an email and password and write journal entries about their cats. Entries are saved in a Supabase PostgreSQL database and show up for everyone in real time, with no page refresh.
Most of the app is already built. Your job is to finish 4 TODOs, and each one teaches a core Supabase concept.
- Go to the Supabase dashboard and click New project.
- Give it a name (e.g.
cat-journal), set a database password, and pick a region near you. - Wait a minute or two while the project starts up.
Open SQL Editor in the sidebar, paste this, and click Run:
create table public.entries (
id uuid primary key default gen_random_uuid(),
title text,
text text,
user_id uuid references auth.users,
created_at timestamptz default now()
);| Column | Type | Notes |
|---|---|---|
id |
uuid | Primary key, generated automatically |
title |
text | The entry's title |
text |
text | The entry's body |
user_id |
uuid | Who wrote it (links to auth.users) |
created_at |
timestamptz | When it was written, set automatically |
RLS decides who can read and write each row. Without policies, nobody can read or write anything through the API. Run this in the SQL Editor:
-- Turn on RLS for the table
alter table public.entries enable row level security;
-- Signed-in users can read all entries
create policy "Users can read all entries"
on public.entries for select
to authenticated
using (true);
-- Signed-in users can only insert entries with their own user_id
create policy "Users can insert their own entries"
on public.entries for insert
to authenticated
with check (auth.uid() = user_id);Run this in the SQL Editor:
alter publication supabase_realtime add table public.entries;(You can also do this in the dashboard: open the table in Table Editor and turn on Realtime.)
Email sign-in is turned on by default in new Supabase projects. You only need to change one setting:
- Go to Authentication > Providers (called Sign In / Providers in newer dashboards) and open Email.
- Make sure Enable Email provider is on.
- Turn off Confirm email, then save.
Why turn off confirmation? By default Supabase emails new users a link they must click before they can sign in, and Supabase's built-in email sender only allows a few emails per hour. With confirmation off, creating an account signs you in right away. (For a real app you'd leave it on and set up your own email provider.)
- Project URL: go to Project Settings > Data API (or click Connect at the top of the dashboard). It looks like
https://<your-project-ref>.supabase.co. - Publishable key: go to Project Settings > API Keys and copy the default publishable key. It starts with
sb_publishable_.- Older projects may only show a legacy anon key (a long string starting with
eyJ). That works in the same place. - Never use a secret key (
sb_secret_...) or the legacyservice_rolekey in a frontend app. Those skip RLS entirely.
- Older projects may only show a legacy anon key (a long string starting with
- Make your own
.envfile by copying the example:
cp .env.example .env- Open
.envand fill in your values:
VITE_SUPABASE_URL=https://your-project-ref.supabase.co
VITE_SUPABASE_PUBLISHABLE_KEY=sb_publishable_your-key-here
The publishable key is meant to be public, since it ends up in the browser anyway. RLS (step 3) is what actually protects your data.
.env is listed in .gitignore, so it never gets committed. .env.example is committed so everyone knows which variables to set. Even though the publishable key isn't secret, keeping config out of git is a good habit. Real projects usually have secret keys in there too.
npm install
npm run devOpen http://localhost:5173.
At first you'll see a sign-in form whose buttons do nothing. That's expected! Work through the TODOs in order. After each one, save the file and the browser updates automatically.
If you change
.env, stop the dev server (Ctrl+C) and runnpm run devagain. Vite only reads.envon startup.
src/
supabaseClient.js // Creates the Supabase client (done for you)
App.jsx // Main component, tracks who is signed in (TODO 2)
Login.jsx // Email/password sign-in form (TODO 1)
EntryForm.jsx // Form to add a new journal entry (TODO 3)
EntryList.jsx // Shows all entries in real time (TODO 4)
main.jsx // Entry point
App.css // Basic styling
Each TODO has detailed comments in its file, including the exact code to write.
Import supabase, then:
- In
handleSignUp, callsupabase.auth.signUp({ email, password }). - In
handleSignIn, callsupabase.auth.signInWithPassword({ email, password }).
What it teaches: Supabase Authentication. Supabase stores your users, checks passwords, and keeps the session in the browser for you.
Check it: create an account, then look in Authentication > Users in Supabase and find your new user. The app still shows the sign-in form. That's because it isn't listening for the login yet, which is TODO 2.
Import supabase, then inside the useEffect call supabase.auth.onAuthStateChange(callback). Set user to session.user when logged in and null when logged out. Return a cleanup function that calls subscription.unsubscribe().
What it teaches: auth listeners, and why React useEffect needs a cleanup function (so listeners don't pile up or leak).
Check it: after signing in you see your email, a Sign out button, and the entry form. Sign out works too.
Import supabase and, on submit, call supabase.from("entries").insert([{ title, text, user_id: user.id }]). The user comes in as a prop from App.jsx.
What it teaches: writing to Supabase. Each object becomes one row, and its keys must match column names. The RLS policy checks that user_id matches the signed-in user, so try putting a different ID there and watch it fail.
Check it: submit an entry, then open Table Editor > entries in Supabase and see your row. It won't appear in the app yet, which is TODO 4.
Import supabase and, inside the useEffect:
- Fetch existing entries with
supabase.from("entries").select("*").order("created_at", { ascending: false }). - Subscribe to inserts with
supabase.channel("entries-changes").on("postgres_changes", { event: "INSERT", schema: "public", table: "entries" }, callback).subscribe(). - In the callback, add
payload.newto the top of the list. - Return a cleanup function that calls
supabase.removeChannel(channel).
What it teaches: real-time subscriptions, Supabase channels, PostgreSQL change events, and cleaning up subscriptions.
Check it: open the app in two browser windows side by side. Add an entry in one and watch it appear in the other instantly.
- Blank page or "supabaseUrl is required":
.envis missing (did you copy.env.example?), isn't filled in, or you didn't restartnpm run devafter editing it. - "Email not confirmed" when signing in: turn off Confirm email (setup step 5), then delete that user in Authentication > Users and sign up again.
- "Email rate limit exceeded": Supabase's built-in email sender is limited. Turn off Confirm email so no emails are sent.
- "Password should be at least 6 characters": that's Supabase's default minimum password length.
- Insert fails with "new row violates row-level security policy":
user_iddoesn't match the signed-in user, or the insert policy is missing. - Entries save but don't appear live: Realtime isn't enabled on the table (step 4), or the select policy is missing. Realtime respects RLS too.