Supabase tutorial: build a first app with auth, a database and row level security
To build a first app with Supabase, create a project, add a table in the SQL editor, enable row level security with a policy for each operation, install @supabase/supabase-js, and create a client with your project URL and publishable key. Then call signUp and signInWithPassword for auth, and from('notes').select() or insert() for data. Row level security is what makes the publishable key safe to ship in the browser.
What will you build in this Supabase tutorial?
A small private notes app in React. Users sign up with an email and password, add notes and see only their own. It touches the four parts of Supabase that most first apps use: the Postgres database, Auth, row level security (RLS) and the JavaScript client. Nothing else is needed.
The code follows Supabase's own documentation: the React quickstart, the Row Level Security guide and the password auth guide. The docs change, so if a step differs on your screen, trust the current docs.
| Part | What it does | Where you use it |
|---|---|---|
| Postgres database | Stores the notes in a table | Step 2, in the SQL editor |
| Row level security | Decides which rows each user can read or change | Step 2, as policies on the table |
| Auth | Signs users up and in, and tells the database who they are | Step 5, with signUp and signInWithPassword |
| JS client (supabase-js) | Sends queries from the browser using the publishable key | Steps 4 to 6 |
You need Node.js installed and basic comfort with a terminal. If you plan to host the finished app, see how to host a website for free.
How do you create a Supabase project?
Create a new project from the Supabase dashboard inside any organization you belong to. Choose a name, a database password and a region near your users. Save the password in a password manager.
As of October 2026, Supabase's pricing page lists the Free plan as including a 500 MB database, 50,000 monthly active users and 1 GB of file storage, with a limit of 2 active projects. It also states that free projects are paused after 1 week of inactivity. That is fine for learning, but not for something people rely on.
How do you create a table with row level security?
Open the SQL Editor in the dashboard and run the script below. It creates a notes table, turns on RLS, sets the grants and adds one policy for each operation. Supabase's RLS guide warns that a table in an exposed schema without RLS is readable and writable by any role with a grant on it, so treat these lines as part of creating the table, not as an extra.
create table public.notes ( id bigint primary key generated always as identity, user_id uuid not null references auth.users (id) on delete cascade, body text not null check (char_length(body) <= 500), created_at timestamptz not null default now() ); -- Turn on row level security alter table public.notes enable row level security; -- Start from no access, then grant only what signed-in users need revoke all on table public.notes from anon, authenticated; grant select, insert, update, delete on table public.notes to authenticated; -- Policies: each user can only touch their own rows create policy "Users can read their own notes" on public.notes for select to authenticated using ( (select auth.uid()) = user_id ); create policy "Users can add their own notes" on public.notes for insert to authenticated with check ( (select auth.uid()) = user_id ); create policy "Users can edit their own notes" on public.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 public.notes for delete to authenticated using ( (select auth.uid()) = user_id ); -- Policies filter on user_id, so index it create index notes_user_id_idx on public.notes (user_id);
What each part does, using the vocabulary of the RLS guide:
- Grants decide whether a role can run an operation on the table at all. Policies decide which rows the operation applies to. You need both. The guide notes that on existing projects new tables in
publiccan start with every privilege granted toanon,authenticatedandservice_role, and that adding policies does not take those grants back. That is why the script revokes first. - `auth.uid()` returns the ID of the user making the request. Writing it as
(select auth.uid())is the form the docs use in their policy examples. - `using` filters rows that already exist. `with check` validates rows being written, so a user cannot insert a note owned by someone else.
- An UPDATE needs a matching SELECT policy. The docs caution that without one, updates do not work as expected. The script has both.
- The index follows the docs' advice to index every column your policies filter on.
How do you connect a React app to Supabase?
Create a React app from the Vite template, as the quickstart does, and install the client library.
npm create vite@latest my-app -- --template react cd my-app && npm install @supabase/supabase-js
Next, create a .env.local file in the project root. Get your project URL and publishable key from the Connect panel in the dashboard.
VITE_SUPABASE_URL=<your project URL> VITE_SUPABASE_PUBLISHABLE_KEY=<your publishable key>
Then create src/lib/supabaseClient.js. This is the code from the quickstart.
import { createClient } from '@supabase/supabase-js'
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL
const supabasePublishableKey = import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY
export const supabase = createClient(supabaseUrl, supabasePublishableKey)Vite bundles variables with the VITE_ prefix into the browser code, which is why only the publishable key belongs here. Keep .env.local out of Git.
How does Supabase Auth work in the JS client?
Email and password sign-in is enabled by default. The password guide notes that on hosted projects, users must confirm their email before they can sign in. Two calls handle the basics: signUp() with an email and password, and signInWithPassword() when the user returns.
// Sign up
const { data, error } = await supabase.auth.signUp({
email: 'valid.email@supabase.io',
password: 'example-password',
})
// Sign in
const { data, error } = await supabase.auth.signInWithPassword({
email: 'valid.email@supabase.io',
password: 'example-password',
})
// Sign out of this browser only
const { error } = await supabase.auth.signOut({ scope: 'local' })Two details from the reference are worth knowing. First, signOut() signs the user out of every device by default. Passing { scope: 'local' } signs out only the current session, which the docs call recommended for most apps. Second, onAuthStateChange() calls your function when an auth event happens, including INITIAL_SESSION, SIGNED_IN and SIGNED_OUT, and returns a subscription you should unsubscribe when the component goes away.
For server-side checks, the reference says getUser() makes a network request to the Auth server, so the returned value is authentic and can be used to base authorization rules on. That is the call to use on a server. In the browser, you read the session from the auth listener.
When a user clicks the link in the confirmation email, the redirect must go to a URL you have allowed. Supabase's docs say additional redirect URLs are configured in the dashboard under authentication URL configuration. Add your local address, http://localhost:5173, while developing, and your live address when you deploy.
What does the full app code look like?
Replace src/App.jsx with the following. It listens for auth changes, shows a sign-in form when nobody is signed in, and shows the user's notes when they are. The queries use the patterns from the JS reference for select, insert and delete.
import { useEffect, useState } from 'react'
import { supabase } from './lib/supabaseClient'
export default function App() {
const [session, setSession] = useState(null)
const [ready, setReady] = useState(false)
useEffect(() => {
const { data } = supabase.auth.onAuthStateChange((event, session) => {
setSession(session)
setReady(true)
})
return () => data.subscription.unsubscribe()
}, [])
if (!ready) return <p>Loading...</p>
return session ? <Notes user={session.user} /> : <SignIn />
}
function SignIn() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [message, setMessage] = useState('')
async function signUp() {
const { error } = await supabase.auth.signUp({ email, password })
setMessage(error ? error.message : 'Check your email to confirm your address.')
}
async function signIn() {
const { error } = await supabase.auth.signInWithPassword({ email, password })
if (error) setMessage(error.message)
}
return (
<div>
<h1>Notes</h1>
<input type="email" placeholder="Email" value={email} onChange={(e) => setEmail(e.target.value)} />
<input type="password" placeholder="Password" value={password} onChange={(e) => setPassword(e.target.value)} />
<button onClick={signIn}>Sign in</button>
<button onClick={signUp}>Sign up</button>
<p>{message}</p>
</div>
)
}
function Notes({ user }) {
const [notes, setNotes] = useState([])
const [body, setBody] = useState('')
const [error, setError] = useState('')
async function loadNotes() {
const { data, error } = await supabase
.from('notes')
.select()
.order('created_at', { ascending: false })
if (error) { setError(error.message); return }
setNotes(data)
}
useEffect(() => { loadNotes() }, [])
async function addNote(e) {
e.preventDefault()
const { error } = await supabase.from('notes').insert({ user_id: user.id, body })
if (error) { setError(error.message); return }
setBody('')
loadNotes()
}
async function deleteNote(id) {
const { error } = await supabase.from('notes').delete().eq('id', id)
if (error) { setError(error.message); return }
loadNotes()
}
return (
<div>
<p>Signed in as {user.email}</p>
<button onClick={() => supabase.auth.signOut({ scope: 'local' })}>Sign out</button>
<form onSubmit={addNote}>
<input value={body} maxLength={500} onChange={(e) => setBody(e.target.value)} placeholder="New note" required />
<button type="submit">Add</button>
</form>
{error && <p>{error}</p>}
<ul>
{notes.map((note) => (
<li key={note.id}>
{note.body} <button onClick={() => deleteNote(note.id)}>Delete</button>
</li>
))}
</ul>
</div>
)
}- Run
npm run devand openhttp://localhost:5173. - Sign up with a real email address, then click the confirmation link.
- Sign in, add a note and reload the page. The note is still there because it lives in Postgres.
The maxLength on the input and the check in the table are the same limit on purpose. The input protects the user from a mistake. The check protects the database from everything else, including a request that never touches your form.
How do you check that row level security works?
Never assume. Test it. Do these three checks before you build anything else on top.
- Two users. Sign up a second account in a private window and add a note. Each account should see only its own note.
- Signed out. In the browser console of the running app, sign out and run a query. You should get a permission error, not rows. The RLS guide says a missing grant raises error
42501before any policy runs. - Someone else's user ID. Try inserting a row with another user's ID. The insert should fail, because the
with checkexpression does not match. The guide lists awith checkviolation as raising42501too.
await supabase.auth.signOut({ scope: 'local' })
const { data, error } = await supabase.from('notes').select()
console.log(data, error)
// Expect an error (code 42501), not a list of rowsThe guide goes further and recommends writing database tests with supabase test db that assert allow and deny for each role and operation. That is worth adding once the app has more than one table.
| Mistake | What goes wrong | Fix |
|---|---|---|
| RLS not enabled on a table | Any role with a grant can read and write it | Run alter table ... enable row level security on every table |
| Policies added but grants left open | anon may still hold insert rights | Revoke first, then grant what each role needs |
to anon using ( true ) on a private table | Every unauthenticated visitor can read every row | Use it only for data meant to be public |
| A view over a protected table | Views bypass RLS by default, so the view leaks rows | Follow the docs on exposing a view safely |
| Secret key in front-end code | Bypasses all policies for anyone who reads the bundle | Use only the publishable key in the browser |
| UPDATE policy without a SELECT policy | Updates silently do nothing | Add the matching select policy |
Can you build this with Claude Code or Cursor instead?
Yes, and the quickstart suggests it. It offers optional AI tooling: Agent Skills, installed with npx skills add supabase/agent-skills, give an AI coding agent procedural knowledge of Supabase patterns, and the Supabase MCP server lets assistants inspect your schema. The quickstart says these help the agent produce code with current patterns, instead of relying on training data alone.
An agent is quick at this app, but it can leave out the security parts. Include them in the prompt, and then run the three tests above yourself.
Build a React + Vite app that uses Supabase with email and password sign-in. Table: notes (id bigint identity, user_id uuid references auth.users on delete cascade, body text max 500 chars, created_at). Security, required: - Enable row level security on notes. - Revoke all privileges from anon and authenticated, then grant select, insert, update, delete to authenticated only. - One policy per operation, each using (select auth.uid()) = user_id. Insert and update use with check. - Index user_id. Client: @supabase/supabase-js with the project URL and the publishable key from VITE_ environment variables. Never use the secret key or service_role key in front-end code. UI: sign up, sign in, sign out with scope local, list my notes, add a note, delete a note. Show error messages. Do not build: social login, file upload, realtime, profiles. Done when: two accounts see only their own notes, and a signed-out select returns a permission error. Show me the SQL and how to test it.
For a wider guide to prompting agents, see how to write an app build prompt. If you build with Lovable, Lovable and Supabase covers how the two connect.
What should you do before you put a Supabase app online?
The quickstart is explicit that it optimizes for a working app, not for production. Its production list says to review your RLS policies, set your credentials as environment variables on the host instead of committing them, and configure a custom domain for your Supabase project when you are ready to go live.
- Check every table. Is RLS on? Are the grants minimal? Run the allow and deny tests.
- Set the allowed redirect URLs for your live address in the Auth settings.
- Add the two environment variables to your host's settings. Free hosting options compared shows where.
- Turn off what you do not use. A feature you did not build should not be exposed.
- Run the advisors. Supabase's Advisors are checks that flag issues such as misconfigured RLS policies and missing indexes. You can run them in the dashboard or through the MCP server.
- Plan for pausing. A free project pauses after a week of inactivity, so move to a paid plan before real users arrive.
If you are looking for something to build with this stack, browse the board. Each idea has a generated build prompt, and notes, lists and trackers that need sign-in are a natural fit for the pattern you just built.
Frequently asked questions
How do I get started with Supabase?
Create a project in the Supabase dashboard, add a table in the SQL editor with row level security enabled, install @supabase/supabase-js in your app, and create a client with your project URL and publishable key. Then call supabase.auth.signUp and supabase.from('table').select(). The React quickstart in the Supabase docs shows each step.
Is Supabase free?
It has a Free plan. As of October 2026 the pricing page lists a 500 MB database, 50,000 monthly active users, 1 GB of file storage and a limit of 2 active projects. Free projects are paused after 1 week of inactivity. Paid plans start at USD 25 a month and are not paused for inactivity.
What is row level security in Supabase?
Row level security is a Postgres feature that applies rules to every query on a table, like adding a WHERE clause. Supabase uses it to decide which rows each user can read or change. The docs say to enable RLS on every table in an exposed schema, and to pair policies with minimal grants.
Is the Supabase publishable key safe to put in my front end?
Yes, with row level security on. Supabase's docs say the publishable key is meant for code you ship, such as browser apps, because anyone can read it and it only reaches what RLS allows. The secret key bypasses RLS and must stay on a server you control.
What is the difference between getUser and getSession?
Supabase's reference says getUser makes a network request to the Auth server, so the result is authentic and can base authorization on. getSession returns the session from the client's storage, refreshing it if necessary, and the reference carries an IMPORTANT note about trusting values loaded directly from storage. Use getUser where you make server-side authorization decisions.
Can I build a Supabase app with an AI coding tool?
Yes. Describe the table, the security rules and the client in your prompt, and tell the tool to enable row level security, revoke unneeded grants and never use the secret key in front-end code. The Supabase docs also offer Agent Skills and an MCP server. Always test with two accounts and a signed-out request.
Created with AI assistance and edited for this site. Examples are illustrative and do not represent customer research. About the publisher.