Complete Supabase Guide for Developers
Thursday, Dec 25, 2025
If you’ve ever used Firebase and felt “this seems too vendor-locked,” welcome to the club. Supabase is here as a powerful open-source alternative, and most importantly: built on PostgreSQL—a database that’s been battle-tested for decades.
In this article, I’ll cover everything you need to know about Supabase. From setup to production-ready. Let’s go!
What is Supabase?
Supabase is an open-source Backend-as-a-Service (BaaS) that provides:
- PostgreSQL Database - Full Postgres with a beautiful GUI
- Authentication - Email, OAuth, Magic Link, Phone Auth
- Storage - Object storage for files and media
- Edge Functions - Serverless functions using Deno
- Realtime - Subscribe to database changes live
- Vector/AI - For AI applications with pgvector
What makes Supabase attractive is you can self-host if you want, or use their managed service. Your data, your rules.
Setting Up a Supabase Project
1. Create Account and Project
First, sign up at supabase.com and create a new project:
- Click “New Project”
- Choose organization (or create new)
- Fill in project name and database password
- Choose region (Singapore is closest for Asia-Pacific)
- Wait a few minutes until provisioning completes
2. Get API Keys
After project is ready, get credentials at Settings > API:
# .env.local
NEXT_PUBLIC_SUPABASE_URL=https://xxxxx.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJxxxxxx
SUPABASE_SERVICE_ROLE_KEY=eyJxxxxxx # Don't expose to client!
Important: ANON_KEY is safe to expose to browser because it’s restricted by Row Level Security. SERVICE_ROLE_KEY bypasses all security, so keep it safe on server only.
Database: Tables, Relationships, and RLS
Creating Tables
You can create tables via GUI in dashboard or use SQL Editor:
-- Create profiles table
CREATE TABLE profiles (
id UUID REFERENCES auth.users ON DELETE CASCADE PRIMARY KEY,
username TEXT UNIQUE,
full_name TEXT,
avatar_url TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Create posts table
CREATE TABLE posts (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
user_id UUID REFERENCES profiles(id) ON DELETE CASCADE NOT NULL,
title TEXT NOT NULL,
content TEXT,
published BOOLEAN DEFAULT FALSE,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Index for performance
CREATE INDEX posts_user_id_idx ON posts(user_id);
CREATE INDEX posts_published_idx ON posts(published) WHERE published = TRUE;
Relationships
Supabase auto-detects foreign keys and creates relationships. Queries with joins become easy:
const { data, error } = await supabase
.from('posts')
.select(`
*,
profiles (
username,
avatar_url
)
`)
.eq('published', true)
.order('created_at', { ascending: false });
Row Level Security (RLS)
RLS is a game-changer. You define security rules at database level, not in application code:
-- Enable RLS
ALTER TABLE posts ENABLE ROW LEVEL SECURITY;
-- Policy: Everyone can read published posts
CREATE POLICY "Public posts are viewable by everyone"
ON posts FOR SELECT
USING (published = TRUE);
-- Policy: User can only CRUD their own posts
CREATE POLICY "Users can manage their own posts"
ON posts FOR ALL
USING (auth.uid() = user_id)
WITH CHECK (auth.uid() = user_id);
-- Policy: User can read their own posts (including drafts)
CREATE POLICY "Users can view their own posts"
ON posts FOR SELECT
USING (auth.uid() = user_id);
Tip: Always test RLS policies using “SQL Editor” with anon or authenticated role.
Authentication
Supabase Auth supports various methods. All built-in, just enable.
Email/Password
// Sign Up
const { data, error } = await supabase.auth.signUp({
email: 'user@example.com',
password: 'super-secret-password',
});
// Sign In
const { data, error } = await supabase.auth.signInWithPassword({
email: 'user@example.com',
password: 'super-secret-password',
});
// Sign Out
await supabase.auth.signOut();
OAuth (Google, GitHub, etc.)
Enable provider in dashboard (Authentication > Providers), then:
const { data, error } = await supabase.auth.signInWithOAuth({
provider: 'google',
options: {
redirectTo: 'https://yourapp.com/auth/callback',
},
});
Magic Link
Perfect for passwordless experience:
const { data, error } = await supabase.auth.signInWithOtp({
email: 'user@example.com',
options: {
emailRedirectTo: 'https://yourapp.com/welcome',
},
});
Handle Auth State
// Listen to auth changes
supabase.auth.onAuthStateChange((event, session) => {
console.log(event, session);
if (event === 'SIGNED_IN') {
// Redirect to dashboard
} else if (event === 'SIGNED_OUT') {
// Redirect to login
}
});
// Get current user
const { data: { user } } = await supabase.auth.getUser();
Storage: Upload and Manage Files
Supabase Storage is similar to S3 but simpler. Perfect for avatars, images, documents, etc.
Setup Bucket
Create bucket in dashboard or via SQL:
-- Create public bucket for avatars
INSERT INTO storage.buckets (id, name, public)
VALUES ('avatars', 'avatars', TRUE);
-- Create private bucket for documents
INSERT INTO storage.buckets (id, name, public)
VALUES ('documents', 'documents', FALSE);
Storage Policies
-- Policy: User can upload to their own folder
CREATE POLICY "Users can upload their own avatar"
ON storage.objects FOR INSERT
WITH CHECK (
bucket_id = 'avatars' AND
auth.uid()::text = (storage.foldername(name))[1]
);
-- Policy: Avatars can be viewed by everyone
CREATE POLICY "Avatar images are publicly accessible"
ON storage.objects FOR SELECT
USING (bucket_id = 'avatars');
Upload Files
// Upload file
const { data, error } = await supabase.storage
.from('avatars')
.upload(`${userId}/avatar.png`, file, {
cacheControl: '3600',
upsert: true,
});
// Get public URL
const { data: { publicUrl } } = supabase.storage
.from('avatars')
.getPublicUrl(`${userId}/avatar.png`);
// Download file (for private buckets)
const { data, error } = await supabase.storage
.from('documents')
.download('path/to/file.pdf');
Edge Functions
Edge Functions are serverless functions that run on Deno. Perfect for:
- Webhooks
- Third-party API calls
- Complex business logic
- Scheduled jobs
Create Function
# Install Supabase CLI
npm install -g supabase
# Init and create function
supabase init
supabase functions new hello-world
Code Function
// supabase/functions/hello-world/index.ts
import { serve } from "https://deno.land/std@0.168.0/http/server.ts";
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
serve(async (req) => {
try {
const { name } = await req.json();
// Access Supabase from function
const supabase = createClient(
Deno.env.get('SUPABASE_URL') ?? '',
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? ''
);
const { data, error } = await supabase
.from('greetings')
.insert({ message: `Hello ${name}!` })
.select()
.single();
return new Response(
JSON.stringify({ data }),
{ headers: { "Content-Type": "application/json" } }
);
} catch (error) {
return new Response(
JSON.stringify({ error: error.message }),
{ status: 500 }
);
}
});
Deploy and Invoke
# Deploy
supabase functions deploy hello-world
# Test locally
supabase functions serve hello-world
// Invoke from client
const { data, error } = await supabase.functions.invoke('hello-world', {
body: { name: 'John' },
});
Realtime Subscriptions
Supabase’s killer feature. Subscribe to database changes in real-time.
Subscribe to Table Changes
// Subscribe to all changes in posts table
const channel = supabase
.channel('posts-changes')
.on(
'postgres_changes',
{
event: '*', // INSERT, UPDATE, DELETE, or *
schema: 'public',
table: 'posts',
},
(payload) => {
console.log('Change received!', payload);
if (payload.eventType === 'INSERT') {
// Handle new post
} else if (payload.eventType === 'UPDATE') {
// Handle updated post
} else if (payload.eventType === 'DELETE') {
// Handle deleted post
}
}
)
.subscribe();
// Unsubscribe
supabase.removeChannel(channel);
Filter Realtime Events
// Only subscribe to posts from specific user
const channel = supabase
.channel('my-posts')
.on(
'postgres_changes',
{
event: 'INSERT',
schema: 'public',
table: 'posts',
filter: `user_id=eq.${userId}`,
},
handleNewPost
)
.subscribe();
Broadcast and Presence
Besides database changes, you can also create custom channels:
// Presence - track online users
const channel = supabase.channel('online-users');
channel
.on('presence', { event: 'sync' }, () => {
const state = channel.presenceState();
console.log('Online users:', Object.keys(state).length);
})
.subscribe(async (status) => {
if (status === 'SUBSCRIBED') {
await channel.track({ user_id: userId, online_at: new Date() });
}
});
Integration with Next.js
Install Dependencies
npm install @supabase/supabase-js @supabase/ssr
Setup Client
// lib/supabase/client.ts
import { createBrowserClient } from '@supabase/ssr';
export function createClient() {
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);
}
// lib/supabase/server.ts
import { createServerClient, type CookieOptions } 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() {
return cookieStore.getAll();
},
setAll(cookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
);
} catch {
// Handle server component
}
},
},
}
);
}
Middleware for Auth
// middleware.ts
import { createServerClient } from '@supabase/ssr';
import { NextResponse, type NextRequest } from 'next/server';
export async function middleware(request: NextRequest) {
let response = NextResponse.next({
request: { headers: request.headers },
});
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return request.cookies.getAll();
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value }) =>
request.cookies.set(name, value)
);
response = NextResponse.next({ request });
cookiesToSet.forEach(({ name, value, options }) =>
response.cookies.set(name, value, options)
);
},
},
}
);
const { data: { user } } = await supabase.auth.getUser();
// Protect routes
if (!user && request.nextUrl.pathname.startsWith('/dashboard')) {
return NextResponse.redirect(new URL('/login', request.url));
}
return response;
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
};
Server Component Example
// app/posts/page.tsx
import { createClient } from '@/lib/supabase/server';
export default async function PostsPage() {
const supabase = await createClient();
const { data: posts } = await supabase
.from('posts')
.select('*, profiles(username)')
.eq('published', true)
.order('created_at', { ascending: false });
return (
<div>
{posts?.map((post) => (
<article key={post.id}>
<h2>{post.title}</h2>
<p>By {post.profiles.username}</p>
</article>
))}
</div>
);
}
Best Practices and Tips
1. Always Use RLS
Never disable RLS in production. Even for admin operations, better to use service role key on server than disable RLS.
2. Optimize Queries
// ❌ Bad: Fetch all columns
const { data } = await supabase.from('posts').select('*');
// ✅ Good: Fetch only what's needed
const { data } = await supabase.from('posts').select('id, title, created_at');
3. Handle Errors Properly
const { data, error } = await supabase.from('posts').select();
if (error) {
console.error('Error fetching posts:', error.message);
// Handle error appropriately
return;
}
// Safe to use data here
4. Use Database Functions for Complex Logic
-- Create function to increment view count atomically
CREATE OR REPLACE FUNCTION increment_view_count(post_id UUID)
RETURNS void AS $$
BEGIN
UPDATE posts SET view_count = view_count + 1 WHERE id = post_id;
END;
$$ LANGUAGE plpgsql;
await supabase.rpc('increment_view_count', { post_id: 'xxx' });
5. Setup Database Triggers
-- Auto-update updated_at timestamp
CREATE OR REPLACE FUNCTION update_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER posts_updated_at
BEFORE UPDATE ON posts
FOR EACH ROW
EXECUTE FUNCTION update_updated_at();
6. Use Connection Pooling
For high-traffic apps, use the pooler connection string (port 6543) instead of direct connection.
Pricing and Free Tier
Supabase has a generous free tier:
| Feature | Free Tier | Pro ($25/mo) |
|---|---|---|
| Database | 500MB | 8GB |
| Storage | 1GB | 100GB |
| Bandwidth | 2GB | 250GB |
| Edge Functions | 500K invocations | 2M invocations |
| Auth | 50K MAU | 100K MAU |
| Realtime | 200 concurrent | 500 concurrent |
Free tier is great for:
- Side projects
- MVP and prototyping
- Learning and experimenting
Upgrade to Pro when you start scaling or need features like:
- Daily backups
- Email support
- More resources
Conclusion
Supabase is a solid choice for modern backend. With PostgreSQL as the foundation, you get:
- Reliability - PostgreSQL has been proven in production for decades
- Flexibility - Full SQL access, can self-host, no vendor lock-in
- Developer Experience - Nice dashboard, intuitive SDK
- Cost Effective - Generous free tier, transparent pricing
If you’re coming from Firebase, transitioning to Supabase is relatively smooth. Concepts are similar, but with the power of SQL and open-source ecosystem.
Start exploring at supabase.com and join their active Discord community. Happy building! 🚀