From MVP to Production: Supabase in the Real World

Kevin Dávila
The day your MVP shows up on Product Hunt unannounced is the day you find out which parts of your stack were toys. Database connections run out, the query that worked with 10 users chokes on 1,000, and the RLS policy that "was fine as it was" becomes your worst nightmare. All at the same time, with real traffic.
The good part: almost everything can be fixed before launch. In this post you get the lessons from taking Supabase to real production: connection pooling, indexes, security, and scaling.
Connection pooling
Supabase limits simultaneous connections to PostgreSQL. For production, you need pooling.
Use Supavisor
Supabase includes Supavisor, their connection pooler. Configure your client to use it:
// src/lib/supabase.ts
import { createClient } from '@supabase/supabase-js'
// For normal queries (uses pooler)
export const supabase = createClient(
process.env.SUPABASE_URL!,
process.env.SUPABASE_ANON_KEY!
)
// For operations that need direct connections (migrations, etc.)
export const supabaseDirect = createClient(
process.env.SUPABASE_URL!.replace('.supabase.co', '-pooler.supabase.co'),
process.env.SUPABASE_ANON_KEY!
)Dashboard configuration
Go to Settings > Database > Connection pooling:
Mode: Transaction (recommended for most cases)
Pool size: 15-25 (free tier), 50+ (pro)
Default timeout: 10 seconds
Performance
Indexes
Create indexes on columns you filter frequently:
-- Index on user_id (very common)
CREATE INDEX idx_tasks_user_id ON tasks(user_id);
-- Composite index for common queries
CREATE INDEX idx_tasks_user_completed ON tasks(user_id, completed);
-- Index for date sorting
CREATE INDEX idx_tasks_created_at ON tasks(created_at DESC);Analyze slow queries
-- View slow queries
SELECT * FROM pg_stat_statements
ORDER BY mean_exec_time DESC
LIMIT 10;
-- Analyze a specific query
EXPLAIN ANALYZE
SELECT * FROM tasks
WHERE user_id = 'uuid'
AND completed = false
ORDER BY created_at DESC;Pagination
Don't fetch all data at once:
// src/pagination.ts
const PAGE_SIZE = 20
export async function getTasks(page: number = 1) {
const from = (page - 1) * PAGE_SIZE
const to = from + PAGE_SIZE - 1
const { data, error, count } = await supabase
.from('tasks')
.select('*', { count: 'exact' })
.range(from, to)
.order('created_at', { ascending: false })
return {
data,
total: count,
page,
totalPages: Math.ceil((count || 0) / PAGE_SIZE)
}
}Production security
Review RLS policies
Before going to production, audit your policies:
-- View all policies
SELECT schemaname, tablename, policyname, permissive, roles, cmd, qual
FROM pg_policies
WHERE schemaname = 'public';Service Role Key
Never expose the service role key on the client. Keep it in server environment variables:
// src/lib/supabase-admin.ts (SERVER ONLY)
import { createClient } from '@supabase/supabase-js'
export const supabaseAdmin = createClient(
process.env.SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!
)Rate limiting
Supabase doesn't have built-in rate limiting. Implement in your Edge Function or API:
// supabase/functions/api/index.ts
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
const rateLimit = new Map<string, { count: number; reset: number }>()
serve(async (req) => {
const ip = req.headers.get('x-forwarded-for') || 'unknown'
const now = Date.now()
const limit = rateLimit.get(ip)
if (limit && limit.count > 100 && now < limit.reset) {
return new Response('Rate limit exceeded', { status: 429 })
}
if (!limit || now > limit.reset) {
rateLimit.set(ip, { count: 1, reset: now + 60000 })
} else {
limit.count++
}
// Your logic here
})Migrations
Supabase CLI
// Create migration
supabase migration new add_tasks_table
// Apply migrations locally
supabase db reset
// Apply to production
supabase db pushMigration structure
supabase/
migrations/
20240101000000_create_users.sql
20240102000000_create_tasks.sql
20240103000000_add_rls_policies.sqlExample migration
-- supabase/migrations/20240102000000_create_tasks.sql
CREATE TABLE tasks (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE,
title TEXT NOT NULL,
completed BOOLEAN DEFAULT false,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
-- Enable RLS
ALTER TABLE tasks ENABLE ROW LEVEL SECURITY;
-- Policies
CREATE POLICY "Users can view own tasks" ON tasks FOR SELECT USING (auth.uid() = user_id);
CREATE POLICY "Users can insert own tasks" ON tasks FOR INSERT WITH CHECK (auth.uid() = user_id);
CREATE POLICY "Users can update own tasks" ON tasks FOR UPDATE USING (auth.uid() = user_id);
CREATE POLICY "Users can delete own tasks" ON tasks FOR DELETE USING (auth.uid() = user_id);
-- Indexes
CREATE INDEX idx_tasks_user_id ON tasks(user_id);
CREATE INDEX idx_tasks_created_at ON tasks(created_at DESC);Monitoring
Logs in the dashboard
Go to Logs to see:
API logs
Auth logs
Database logs
Edge Function logs
Important metrics
Monitor:
Active connections: Should not reach the limit
Query duration: Queries > 100ms need optimization
Error rate: Increase indicates problems
Storage usage: Don't exceed the plan
Alerts
Configure alerts in your hosting provider (Vercel, Netlify, etc.) for:
500 errors
High latency
Service downtime
Backups
Supabase backups
Free tier: Daily backups, 7-day retention
Pro: Daily backups, 28-day retention
Team/Enterprise: Point-in-time recovery
Manual backup
// Backup database
supabase db dump > backup.sql
// Restore
psql -h db.xxx.supabase.co -U postgres -d postgres < backup.sqlCosts
Free tier limits
Resource | Limit |
|---|---|
Database | 500 MB |
Storage | 1 GB |
Auth users | 50,000 |
Bandwidth | 500 MB |
Edge Functions | 500K invocations |
Optimize costs
Database: Use indexes, optimize queries, archive old data
Storage: Compress images, use CDN, delete unused files
Bandwidth: Use image transformations, cache responses
Edge Functions: Reduce invocations, use batch operations
When to upgrade
Pro ($25/month): When you need more than 500MB DB or better backups
Team ($599/month): When you need SOC2, HIPAA, or priority support
Production checklist
Before launching:
[ ] RLS enabled on all tables
[ ] Service role key only on server
[ ] Indexes on frequently queried columns
[ ] Connection pooling configured
[ ] Versioned migrations
[ ] Backups configured
[ ] Monitoring active
[ ] Rate limiting implemented
[ ] Solid error handling
[ ] Centralized logs
What's next
In the next (and final) article we cover Supabase for Mobile: how to use Supabase with Flutter and React Native for native apps.