Supabase in Production: What We Learned Shipping 15 Projects
We've shipped 15+ production projects on Supabase in the last 18 months. Some for startups handling hundreds of users, others for enterprise clients processing thousands of requests per second. Here's everything we've learned — the good, the traps, and the patterns that scale.
Row-Level Security (RLS) is not optional. Every table that holds user data must have RLS enabled with proper policies. The moment you skip RLS for 'convenience', you've created a security hole. We enforce a team rule: no table goes live without at least one RLS policy. The pattern: `auth.uid() = user_id` for user-owned data, role-based policies for admin access, and `service_role` key only in server-side edge functions — never exposed to the client.
Edge Functions for anything sensitive. Don't put business logic that touches payments, sends emails, or calls third-party APIs in client-side code. Supabase Edge Functions run on Deno Deploy, support TypeScript natively, and have access to environment variables for API keys. We use them for: webhook handlers, payment processing (Razorpay/Stripe), email sending (Resend), and any operation that needs the `service_role` key.
Database design patterns that save you later: use UUIDs for primary keys (not auto-increment — they leak information about your data volume). Add created_at and updated_at timestamps to every table (use triggers for updated_at). Use soft deletes (a `deleted_at` column) instead of hard deletes for any data that might need recovery. Create database views for complex joins you query frequently — they're faster and cleaner than repeated client-side joins.
Realtime subscriptions — use sparingly. Supabase Realtime is powerful but has limits. Each subscription opens a WebSocket connection. At scale, this means: subscribe only to the specific rows/tables you need (use filters), unsubscribe on component unmount (memory leaks are the #1 Realtime bug), and for high-frequency updates (chat, live dashboards), consider polling at 2–5 second intervals instead of Realtime — it's more predictable.
Storage best practices: use separate buckets for public and private files. Enable image transformation on the CDN level (Supabase supports this) instead of processing images in your app. Set up bucket policies that mirror your RLS patterns. For user uploads, always validate file type and size on both client and server — never trust the client.
Performance tuning: create indexes on columns you filter or sort by frequently (Supabase UI makes this easy). Use `select('column1, column2')` instead of `select('*')` — pulling unnecessary columns adds latency. For paginated lists, use cursor-based pagination (where id > last_id limit 20) instead of offset pagination (which gets slower as offset increases). Monitor slow queries in the Supabase dashboard and add indexes proactively.
The migration workflow: never modify production database schema directly. Use Supabase CLI's migration system: `supabase db diff` to generate migration files from your local changes, review them, then `supabase db push` to apply. Keep migration files in version control. This has saved us from at least 5 production data disasters.
Building AI-heavy SaaS products, running a digital agency, and sharing everything I learn along the way.
Ready to build something extraordinary?
Book a free 30-minute strategy call. No pitch decks, no fluff — just a clear plan for your project.