March 27, 202612 min read

Supabase: Build a Full Backend in an Afternoon (Seriously)

Learn Supabase from scratch. Postgres database, authentication, real-time subscriptions, storage, edge functions, and a React todo app.

supabase backend database authentication tutorial
Ad 336x280

Backend development used to take weeks. Set up a server, configure a database, build an auth system, handle file uploads, write API endpoints. Even with frameworks, it's a lot of work before you can do anything interesting.

Supabase gives you all of that in about 10 minutes. It's an open-source Firebase alternative built on top of PostgreSQL, and the developer experience is genuinely impressive. You get a full Postgres database, authentication, real-time subscriptions, file storage, and edge functions -- all from a single dashboard and a JavaScript client library.

The "open source" part matters. Your data lives in a real Postgres database. If you outgrow Supabase or want to self-host, you can export your data and run Postgres anywhere. No vendor lock-in.

What You Get

Supabase bundles several open-source tools:

  • PostgreSQL -- The actual database. Full SQL, joins, indexes, everything.
  • GoTrue -- Authentication. Email/password, OAuth (Google, GitHub, etc.), magic links.
  • Realtime -- WebSocket-based real-time subscriptions. Changes to your database are broadcast to connected clients instantly.
  • Storage -- S3-compatible file storage with access controls tied to your auth.
  • Edge Functions -- Deno-based serverless functions for custom logic.
  • PostgREST -- Automatically generates a REST API from your database schema.

Project Setup

  1. Go to supabase.com and create a free account
  2. Click "New Project"
  3. Choose a name, set a database password (save this), and select a region
  4. Wait about 2 minutes for provisioning
Once your project is ready, grab your credentials from Settings > API:
  • Project URL -- https://xxxxx.supabase.co
  • Anon Key -- Safe for client-side use (row-level security protects your data)

Install the Client Library

npm install @supabase/supabase-js
// lib/supabase.js
import { createClient } from '@supabase/supabase-js';

const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
const supabaseKey = import.meta.env.VITE_SUPABASE_ANON_KEY;

export const supabase = createClient(supabaseUrl, supabaseKey);

Store credentials in a .env file:

VITE_SUPABASE_URL=https://xxxxx.supabase.co
VITE_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIs...

The anon key is designed to be used in client-side code. Security comes from Row Level Security (RLS), not from hiding the key.

Creating Database Tables

You can create tables through the Supabase dashboard (Table Editor) or with SQL. SQL is better because it's reproducible:

-- Go to SQL Editor in the Supabase dashboard and run this

create table todos (
id uuid default gen_random_uuid() primary key,
user_id uuid references auth.users(id) not null,
title text not null,
description text,
completed boolean default false,
created_at timestamptz default now(),
updated_at timestamptz default now()
);

-- Create an index for faster queries by user
create index todos_user_id_idx on todos(user_id);

-- Enable Row Level Security
alter table todos enable row level security;

-- Policy: Users can only see their own todos
create policy "Users can view own todos"
on todos for select
using (auth.uid() = user_id);

-- Policy: Users can insert their own todos
create policy "Users can create todos"
on todos for insert
with check (auth.uid() = user_id);

-- Policy: Users can update their own todos
create policy "Users can update own todos"
on todos for update
using (auth.uid() = user_id);

-- Policy: Users can delete their own todos
create policy "Users can delete own todos"
on todos for delete
using (auth.uid() = user_id);

Row Level Security (RLS) is the key concept. Without it, anyone with your anon key could read all data. With RLS, each user can only access their own rows. The auth.uid() function returns the ID of the currently authenticated user.

CRUD Operations

Supabase generates a client API from your database schema automatically:

import { supabase } from './lib/supabase';

// CREATE
async function createTodo(title, description = '') {
const { data, error } = await supabase
.from('todos')
.insert({
title,
description,
user_id: (await supabase.auth.getUser()).data.user.id
})
.select()
.single();

if (error) throw error;
return data;
}

// READ
async function getTodos() {
const { data, error } = await supabase
.from('todos')
.select('*')
.order('created_at', { ascending: false });

if (error) throw error;
return data;
}

// READ with filtering
async function getIncompleteTodos() {
const { data, error } = await supabase
.from('todos')
.select('*')
.eq('completed', false)
.order('created_at', { ascending: false });

if (error) throw error;
return data;
}

// UPDATE
async function toggleTodo(id, completed) {
const { data, error } = await supabase
.from('todos')
.update({ completed, updated_at: new Date().toISOString() })
.eq('id', id)
.select()
.single();

if (error) throw error;
return data;
}

// DELETE
async function deleteTodo(id) {
const { error } = await supabase
.from('todos')
.delete()
.eq('id', id);

if (error) throw error;
}

The .select() after .insert() or .update() returns the created/updated row. Without it, you just get a success/error response.

Authentication

Supabase Auth handles the entire auth flow -- signup, login, password reset, OAuth, session management:

// Sign up with email/password
async function signUp(email, password) {
  const { data, error } = await supabase.auth.signUp({
    email,
    password,
  });

if (error) throw error;
// User receives a confirmation email by default
return data;
}

// Sign in
async function signIn(email, password) {
const { data, error } = await supabase.auth.signInWithPassword({
email,
password,
});

if (error) throw error;
return data;
}

// Sign in with Google (or GitHub, Discord, etc.)
async function signInWithGoogle() {
const { data, error } = await supabase.auth.signInWithOAuth({
provider: 'google',
options: {
redirectTo: window.location.origin + '/dashboard'
}
});

if (error) throw error;
}

// Sign out
async function signOut() {
const { error } = await supabase.auth.signOut();
if (error) throw error;
}

// Get current user
async function getCurrentUser() {
const { data: { user } } = await supabase.auth.getUser();
return user;
}

// Listen for auth changes
supabase.auth.onAuthStateChange((event, session) => {
if (event === 'SIGNED_IN') {
console.log('User signed in:', session.user.email);
} else if (event === 'SIGNED_OUT') {
console.log('User signed out');
}
});

For OAuth providers (Google, GitHub, etc.), configure them in the Supabase dashboard under Authentication > Providers. You'll need to add your OAuth client ID and secret from the respective provider.

Real-Time Subscriptions

This is where Supabase feels like magic. Subscribe to database changes and your UI updates instantly:

// Subscribe to all changes on the todos table
const channel = supabase
  .channel('todos-changes')
  .on(
    'postgres_changes',
    {
      event: '',        // 'INSERT', 'UPDATE', 'DELETE', or '' for all
      schema: 'public',
      table: 'todos',
      filter: user_id=eq.${userId}  // Only changes for this user
    },
    (payload) => {
      console.log('Change received:', payload);

switch (payload.eventType) {
case 'INSERT':
// Add new todo to local state
setTodos(prev => [payload.new, ...prev]);
break;
case 'UPDATE':
// Update existing todo in local state
setTodos(prev =>
prev.map(t => t.id === payload.new.id ? payload.new : t)
);
break;
case 'DELETE':
// Remove deleted todo from local state
setTodos(prev =>
prev.filter(t => t.id !== payload.old.id)
);
break;
}
}
)
.subscribe();

// Clean up when done
// channel.unsubscribe();

To enable real-time on your table, go to Database > Replication in the dashboard and toggle on the tables you want to broadcast changes for.

File Storage

Upload, download, and manage files with access controls:

// Upload a file
async function uploadAvatar(file, userId) {
  const fileExt = file.name.split('.').pop();
  const filePath = ${userId}/avatar.${fileExt};

const { data, error } = await supabase.storage
.from('avatars') // bucket name
.upload(filePath, file, {
cacheControl: '3600',
upsert: true // Overwrite if exists
});

if (error) throw error;
return data;
}

// Get a public URL
function getAvatarUrl(userId) {
const { data } = supabase.storage
.from('avatars')
.getPublicUrl(${userId}/avatar.jpg);

return data.publicUrl;
}

// Download a file
async function downloadFile(bucket, path) {
const { data, error } = await supabase.storage
.from(bucket)
.download(path);

if (error) throw error;
return data; // Blob
}

// List files
async function listFiles(bucket, folder) {
const { data, error } = await supabase.storage
.from(bucket)
.list(folder, {
limit: 100,
sortBy: { column: 'created_at', order: 'desc' }
});

if (error) throw error;
return data;
}

// Delete a file
async function deleteFile(bucket, path) {
const { error } = await supabase.storage
.from(bucket)
.remove([path]);

if (error) throw error;
}

Create buckets in the dashboard under Storage. Set them as public (anyone can read) or private (requires auth). Storage policies work similarly to RLS policies on tables.

Edge Functions

For custom server-side logic, Supabase offers Edge Functions (Deno-based):

# Install Supabase CLI
npm install -g supabase

# Create a new function
supabase functions new send-welcome-email
// supabase/functions/send-welcome-email/index.ts
import { serve } from "https://deno.land/std@0.177.0/http/server.ts";
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";

serve(async (req) => {
try {
const { user_id, email } = await req.json();

// Create Supabase client with service role key (server-side only)
const supabase = createClient(
Deno.env.get("SUPABASE_URL")!,
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!
);

// Your custom logic here
// Send email, process payment, call external API, etc.

return new Response(
JSON.stringify({ message: "Welcome email sent" }),
{ headers: { "Content-Type": "application/json" } }
);
} catch (error) {
return new Response(
JSON.stringify({ error: error.message }),
{ status: 500, headers: { "Content-Type": "application/json" } }
);
}
});

# Deploy
supabase functions deploy send-welcome-email

Call from your client:

const { data, error } = await supabase.functions.invoke('send-welcome-email', {
  body: { user_id: user.id, email: user.email }
});

Building a Todo App with React

Let's put it all together into a complete React application:

// App.jsx
import { useState, useEffect } from 'react';
import { supabase } from './lib/supabase';

function App() {
const [user, setUser] = useState(null);
const [todos, setTodos] = useState([]);
const [newTodo, setNewTodo] = useState('');
const [loading, setLoading] = useState(true);

// Check auth state
useEffect(() => {
supabase.auth.getUser().then(({ data: { user } }) => {
setUser(user);
setLoading(false);
});

const { data: { subscription } } = supabase.auth.onAuthStateChange(
(_event, session) => {
setUser(session?.user ?? null);
}
);

return () => subscription.unsubscribe();
}, []);

// Fetch todos and subscribe to changes
useEffect(() => {
if (!user) return;

fetchTodos();

const channel = supabase
.channel('todos')
.on('postgres_changes', {
event: '*',
schema: 'public',
table: 'todos',
filter: user_id=eq.${user.id}
}, (payload) => {
if (payload.eventType === 'INSERT') {
setTodos(prev => [payload.new, ...prev]);
} else if (payload.eventType === 'UPDATE') {
setTodos(prev => prev.map(t => t.id === payload.new.id ? payload.new : t));
} else if (payload.eventType === 'DELETE') {
setTodos(prev => prev.filter(t => t.id !== payload.old.id));
}
})
.subscribe();

return () => channel.unsubscribe();
}, [user]);

async function fetchTodos() {
const { data } = await supabase
.from('todos')
.select('*')
.order('created_at', { ascending: false });
setTodos(data || []);
}

async function addTodo(e) {
e.preventDefault();
if (!newTodo.trim()) return;

await supabase.from('todos').insert({
title: newTodo.trim(),
user_id: user.id
});

setNewTodo('');
}

async function toggleTodo(id, completed) {
await supabase
.from('todos')
.update({ completed: !completed })
.eq('id', id);
}

async function deleteTodo(id) {
await supabase.from('todos').delete().eq('id', id);
}

if (loading) return <div>Loading...</div>;

if (!user) {
return <AuthForm />;
}

return (
<div style={{ maxWidth: 600, margin: '0 auto', padding: 20 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h1>My Todos</h1>
<button onClick={() => supabase.auth.signOut()}>Sign Out</button>
</div>

<form onSubmit={addTodo} style={{ display: 'flex', gap: 8, marginBottom: 20 }}>
<input
type="text"
value={newTodo}
onChange={(e) => setNewTodo(e.target.value)}
placeholder="What needs to be done?"
style={{ flex: 1, padding: 8 }}
/>
<button type="submit">Add</button>
</form>

<ul style={{ listStyle: 'none', padding: 0 }}>
{todos.map(todo => (
<li key={todo.id} style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: 12, borderBottom: '1px solid #eee'
}}>
<input
type="checkbox"
checked={todo.completed}
onChange={() => toggleTodo(todo.id, todo.completed)}
/>
<span style={{
flex: 1,
textDecoration: todo.completed ? 'line-through' : 'none',
color: todo.completed ? '#999' : '#000'
}}>
{todo.title}
</span>
<button onClick={() => deleteTodo(todo.id)}>Delete</button>
</li>
))}
</ul>

{todos.length === 0 && <p style={{ color: '#999' }}>No todos yet. Add one above.</p>}
</div>
);
}

function AuthForm() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [isSignUp, setIsSignUp] = useState(false);
const [message, setMessage] = useState('');

async function handleSubmit(e) {
e.preventDefault();
setMessage('');

const { error } = isSignUp
? await supabase.auth.signUp({ email, password })
: await supabase.auth.signInWithPassword({ email, password });

if (error) {
setMessage(error.message);
} else if (isSignUp) {
setMessage('Check your email for a confirmation link.');
}
}

return (
<div style={{ maxWidth: 400, margin: '100px auto', padding: 20 }}>
<h1>{isSignUp ? 'Sign Up' : 'Sign In'}</h1>
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<input type="email" placeholder="Email" value={email}
onChange={(e) => setEmail(e.target.value)} required />
<input type="password" placeholder="Password" value={password}
onChange={(e) => setPassword(e.target.value)} required minLength={6} />
<button type="submit">{isSignUp ? 'Sign Up' : 'Sign In'}</button>
</form>
{message && <p>{message}</p>}
<p>
{isSignUp ? 'Already have an account?' : "Don't have an account?"}{' '}
<button onClick={() => setIsSignUp(!isSignUp)} style={{ background: 'none', border: 'none', color: 'blue', cursor: 'pointer' }}>
{isSignUp ? 'Sign In' : 'Sign Up'}
</button>
</p>
</div>
);
}

export default App;

That's a complete app with authentication, CRUD operations, and real-time updates. Open it in two browser tabs and watch changes sync instantly.

Common Mistakes

Forgetting to enable RLS. Without Row Level Security, your anon key gives anyone full access to your database. Always enable RLS on every table and create appropriate policies. Using the service role key on the client. The service role key bypasses all RLS policies. It should only be used in server-side code (edge functions, backend servers). The anon key is for client-side code. Not enabling Realtime replication. Real-time subscriptions only work on tables that have replication enabled in Database > Replication. Overcomplicating RLS policies. Start with simple policies (auth.uid() = user_id) and add complexity only when needed. Test your policies by trying to access data as different users. Not using .select() after mutations. Without .select(), inserts and updates return no data. You'll need an extra query to get the created/updated row.

What's Next

You now have a working understanding of Supabase: database, auth, real-time, storage, and edge functions. From here, explore database functions (for server-side logic in SQL), database triggers (for automatic actions), and the Supabase CLI for local development.

Supabase is particularly good for MVPs and side projects where you want to move fast without compromising on a real database. It scales well too -- many production apps run on it.

Build more full-stack projects and sharpen your skills at CodeUp.

Ad 728x90