Building on Antigravity and Supabase — Auth, RLS, Edge Functions, and the 4-Second Query That Taught Me Policy Performance
Implementation patterns for shipping production apps with Antigravity and Supabase — Auth, RLS, Edge Functions, and Realtime — plus why enabling RLS can make queries crawl, how to measure and fix it, and where solo-dev costs actually break.
The morning after I enabled RLS, a list query took four seconds
When I moved the backend of one of my side projects onto Supabase, the thing that tripped me up first wasn't auth, and it wasn't storage. It was RLS. The morning after I enabled row-level security on every table, a list query that used to return in about 80ms was taking nearly four seconds — on local test data.
The policies were written exactly as the Supabase docs describe. The indexes were there. It was still slow. It took me the better part of a day, staring at EXPLAIN ANALYZE output, to find out why.
Supabase is a genuinely well-built foundation: PostgreSQL at the core, with auth, storage, Edge Functions, and Realtime layered on top. As an indie developer shipping apps on the App Store and Google Play alongside a day's other work, it lifts a weight that's hard to carry alone. Pair it with Antigravity and you can move from schema design to RLS policy generation to Edge Function code without losing the thread of your project's context.
But whether that generated code survives production is a separate question. There's a real distance between a policy that works and a policy that's both fast and safe.
This article walks the full set of implementation patterns — Auth, RLS, Edge Functions, Realtime, Storage — and then covers the two judgment calls I actually had to make to close that distance: policy performance, and the point where a solo project's costs start to move. Where those four seconds went is answered after Chapter 3.
Chapter 1: Project Setup and Antigravity Initial Configuration
Preparing Your Supabase Project
Start by installing the Supabase CLI and setting up a local development environment. Run the following in Antigravity's terminal:
# Install Supabase CLInpm install supabase --save-dev# Initialize the projectnpx supabase init# Start local Supabase (requires Docker)npx supabase start
After startup, you'll see output like this:
Started supabase local development setup.
API URL: http://127.0.0.1:54321
GraphQL URL: http://127.0.0.1:54321/graphql/v1
S3 Storage URL: http://127.0.0.1:54321/storage/v1/s3
DB URL: postgresql://postgres:postgres@127.0.0.1:54322/postgres
Studio URL: http://127.0.0.1:54323
Inbucket URL: http://127.0.0.1:54324
JWT secret: super-secret-jwt-token-with-at-least-32-characters-long
anon key: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
service_role key: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
Configuring Antigravity's RULES.md
Set up your RULES.md (or .antigravity/rules.md) so Antigravity can accurately understand your project context:
# Project Rules## Tech Stack- Next.js 15+ App Router- Supabase (PostgreSQL + Auth + Storage + Realtime)- TypeScript (strict mode)- Tailwind CSS## Supabase Conventions- Table names: snake_case (plural)- Column names: snake_case- RLS must be enabled on all tables- service_role key is server-side only## Security Rules- Client-side code uses anon key only- All user data access must be controlled by RLS- service_role is only used inside Edge Functions
With this configuration, Antigravity will automatically apply security best practices during code generation.
Setting Up the TypeScript Client
// lib/supabase/client.ts — for client componentsimport { createBrowserClient } from '@supabase/ssr'import type { Database } from '@/types/supabase'export function createClient() { return createBrowserClient<Database>( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! )}// lib/supabase/server.ts — for server components and Server Actionsimport { createServerClient } from '@supabase/ssr'import { cookies } from 'next/headers'import type { Database } from '@/types/supabase'export async function createClient() { const cookieStore = await cookies() return createServerClient<Database>( 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 { // Safe to ignore when called from Server Components } }, }, } )}
By keeping these files in view, Antigravity will correctly choose the right client in all subsequent code generation.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦Diagnose why enabling RLS tanks query latency, then fix it by hoisting auth.uid() into an InitPlan — measured with EXPLAIN ANALYZE, before and after
✦Write multi-tenant RLS policies that cross-reference tables without hitting infinite recursion, using a properly locked-down security definer function
✦Estimate where a solo project actually outgrows the Supabase free tier — egress and Realtime connections bite long before row count does
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Supabase Auth provides JWT-based authentication with support for email/password, magic links, and OAuth (Google, GitHub, etc.). Here's the recommended pattern for Next.js App Router:
// app/auth/callback/route.ts — OAuth callback handlerimport { createClient } from '@/lib/supabase/server'import { NextResponse } from 'next/server'export async function GET(request: Request) { const { searchParams, origin } = new URL(request.url) const code = searchParams.get('code') const next = searchParams.get('next') ?? '/' if (code) { const supabase = await createClient() const { error } = await supabase.auth.exchangeCodeForSession(code) if (!error) { return NextResponse.redirect(`${origin}${next}`) } } // On error, redirect to error page return NextResponse.redirect(`${origin}/auth/error`)}
Antigravity understands this middleware pattern, meaning a simple instruction like "add an auth guard to the dashboard" will produce the right configuration automatically.
Extending User Profiles
Since auth.users can't be modified directly, user profile data is best managed in a public.profiles table:
-- supabase/migrations/20260404_create_profiles.sql-- Create profiles tablecreate table public.profiles ( id uuid references auth.users on delete cascade not null primary key, username text unique, full_name text, avatar_url text, subscription_tier text default 'free' check (subscription_tier in ('free', 'pro', 'premium')), created_at timestamptz default now() not null, updated_at timestamptz default now() not null);-- Enable RLS (required)alter table public.profiles enable row level security;-- Policy: users can only view their own profilecreate policy "Users can view own profile" on public.profiles for select using (auth.uid() = id);-- Policy: users can only update their own profilecreate policy "Users can update own profile" on public.profiles for update using (auth.uid() = id);-- Trigger to automatically create a profile on user signupcreate or replace function public.handle_new_user()returns trigger as $$begin insert into public.profiles (id, full_name, avatar_url) values ( new.id, new.raw_user_meta_data->>'full_name', new.raw_user_meta_data->>'avatar_url' ); return new;end;$$ language plpgsql security definer;create trigger on_auth_user_created after insert on auth.users for each row execute procedure public.handle_new_user();
When having Antigravity create this migration, simply say "create a schema that automatically generates a profile on new user signup and secures it with RLS" — and it will produce equivalent code.
Chapter 3: Practical RLS Implementation Patterns
Why RLS Is Non-Negotiable
RLS is the heart of Supabase security. By enforcing access control at the database level, it eliminates the risk of data leaks caused by application code bugs.
When designing RLS with Antigravity, start by describing your access requirements in natural language. Antigravity will generate the right policies based on your description.
RLS Patterns for Multi-Tenant SaaS
-- supabase/migrations/20260404_create_teams.sql-- Teams tablecreate table public.teams ( id uuid default gen_random_uuid() primary key, name text not null, owner_id uuid references auth.users not null, plan text default 'free' check (plan in ('free', 'pro', 'enterprise')), created_at timestamptz default now() not null);-- Team members junction tablecreate table public.team_members ( team_id uuid references public.teams on delete cascade not null, user_id uuid references auth.users on delete cascade not null, role text default 'member' check (role in ('owner', 'admin', 'member')), joined_at timestamptz default now() not null, primary key (team_id, user_id));-- Projects table (scoped to a team)create table public.projects ( id uuid default gen_random_uuid() primary key, team_id uuid references public.teams on delete cascade not null, name text not null, description text, status text default 'active' check (status in ('active', 'archived')), created_at timestamptz default now() not null, updated_at timestamptz default now() not null);-- Enable RLSalter table public.teams enable row level security;alter table public.team_members enable row level security;alter table public.projects enable row level security;-- Helper: check if current user is a team membercreate or replace function public.is_team_member(team_id uuid)returns boolean as $$ select exists ( select 1 from public.team_members where team_members.team_id = $1 and team_members.user_id = auth.uid() )$$ language sql security definer stable;-- Helper: check if current user is a team admincreate or replace function public.is_team_admin(team_id uuid)returns boolean as $$ select exists ( select 1 from public.team_members where team_members.team_id = $1 and team_members.user_id = auth.uid() and team_members.role in ('owner', 'admin') )$$ language sql security definer stable;-- Teams policiescreate policy "Team members can view their teams" on public.teams for select using (public.is_team_member(id));create policy "Team owners can update team" on public.teams for update using (owner_id = auth.uid());-- Projects policiescreate policy "Team members can view projects" on public.projects for select using (public.is_team_member(team_id));create policy "Team admins can create projects" on public.projects for insert with check (public.is_team_admin(team_id));create policy "Team admins can update projects" on public.projects for update using (public.is_team_admin(team_id));
Type-Safe Queries with RLS
// lib/supabase/queries/projects.tsimport { createClient } from '@/lib/supabase/server'import type { Database } from '@/types/supabase'type Project = Database['public']['Tables']['projects']['Row']export async function getProjectsByTeam(teamId: string): Promise<Project[]> { const supabase = await createClient() const { data, error } = await supabase .from('projects') .select(` *, team:teams(name, plan) `) .eq('team_id', teamId) .eq('status', 'active') .order('created_at', { ascending: false }) if (error) { console.error('Failed to fetch projects:', error) throw new Error(error.message) } // RLS automatically ensures only authorized projects are returned return data}
Chapter 4: Supabase Edge Functions
When to Use Edge Functions
Edge Functions run on the Deno runtime and shine in these scenarios:
Receiving and processing Stripe Webhooks
Calling OpenAI / Gemini APIs (keeping API keys server-side)
Executing complex business logic
Integrating third-party APIs
Generating an Edge Function with Antigravity
Tell Antigravity: "Create an Edge Function that receives Stripe Webhooks and updates the user's plan when payment is complete." It will generate something like this:
// supabase/functions/stripe-webhook/index.tsimport Stripe from 'npm:stripe@14'import { createClient } from 'npm:@supabase/supabase-js@2'const stripe = new Stripe(Deno.env.get('STRIPE_SECRET_KEY')!, { httpClient: Stripe.createFetchHttpClient(),})const supabase = createClient( Deno.env.get('SUPABASE_URL')!, Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')! // service_role is safe inside Edge Functions)Deno.serve(async (req) => { const signature = req.headers.get('stripe-signature')! const body = await req.text() let event: Stripe.Event try { event = await stripe.webhooks.constructEventAsync( body, signature, Deno.env.get('STRIPE_WEBHOOK_SECRET')! ) } catch (err) { console.error('Webhook signature verification failed:', err) return new Response('Invalid signature', { status: 400 }) } if (event.type === 'checkout.session.completed') { const session = event.data.object as Stripe.Checkout.Session const userId = session.metadata?.user_id const plan = session.metadata?.plan if (userId && plan) { const { error } = await supabase .from('profiles') .update({ subscription_tier: plan, updated_at: new Date().toISOString(), }) .eq('id', userId) if (error) { console.error('Failed to update subscription:', error) return new Response('Database error', { status: 500 }) } } } if (event.type === 'customer.subscription.deleted') { const subscription = event.data.object as Stripe.Subscription const userId = subscription.metadata?.user_id if (userId) { await supabase .from('profiles') .update({ subscription_tier: 'free', updated_at: new Date().toISOString() }) .eq('id', userId) } } return new Response(JSON.stringify({ received: true }), { headers: { 'Content-Type': 'application/json' }, })})
Deploying and Managing Edge Functions
# Serve locally for developmentnpx supabase functions serve stripe-webhook --env-file .env.local# Deploy to productionnpx supabase functions deploy stripe-webhook --no-verify-jwt# Set secretsnpx supabase secrets set STRIPE_SECRET_KEY=sk_live_...npx supabase secrets set STRIPE_WEBHOOK_SECRET=whsec_...
Chapter 5: Real-Time Features with Supabase Realtime
How Supabase Realtime Works
Supabase Realtime is a WebSocket-based change notification system. Leveraging PostgreSQL's logical replication, it delivers database changes to clients in real time. There are three modes:
Broadcast: Low-latency message delivery between clients
Presence: Managing online user state
Postgres Changes: Receiving database changes in real time
Building an AI Chat App with Realtime
When implementing AI chat with Antigravity, using Realtime lets you elegantly handle streaming responses:
// app/actions/upload.ts'use server'import { createClient } from '@/lib/supabase/server'import { revalidatePath } from 'next/cache'export async function uploadFile(formData: FormData) { const supabase = await createClient() const { data: { user }, } = await supabase.auth.getUser() if (!user) { return { error: 'Authentication required' } } const file = formData.get('file') as File if (!file) { return { error: 'No file selected' } } // File size limit (5MB) if (file.size > 5 * 1024 * 1024) { return { error: 'File size must be 5MB or less' } } // Store in a per-user directory const fileName = `${Date.now()}-${file.name.replace(/[^a-zA-Z0-9.-]/g, '_')}` const filePath = `${user.id}/${fileName}` const { error } = await supabase.storage .from('user-uploads') .upload(filePath, file, { cacheControl: '3600', upsert: false, }) if (error) { console.error('Upload error:', error) return { error: 'Upload failed' } } // Save file metadata to DB await supabase.from('files').insert({ user_id: user.id, file_path: filePath, file_name: file.name, file_size: file.size, mime_type: file.type, }) revalidatePath('/dashboard/files') return { success: true, path: filePath }}export async function getSignedUrl(filePath: string) { const supabase = await createClient() // Signed URL valid for 1 hour const { data, error } = await supabase.storage .from('user-uploads') .createSignedUrl(filePath, 3600) if (error) return { error: error.message } return { url: data.signedUrl }}
Storage Bucket Policies
-- Users can only upload to their own directorycreate policy "Users can upload own files" on storage.objects for insert to authenticated with check ( bucket_id = 'user-uploads' and (storage.foldername(name))[1] = auth.uid()::text );-- Users can only view their own filescreate policy "Users can view own files" on storage.objects for select to authenticated using ( bucket_id = 'user-uploads' and (storage.foldername(name))[1] = auth.uid()::text );-- Users can only delete their own filescreate policy "Users can delete own files" on storage.objects for delete to authenticated using ( bucket_id = 'user-uploads' and (storage.foldername(name))[1] = auth.uid()::text );
Chapter 7: Type Generation and Developer Experience
Automatic TypeScript Type Generation
The Supabase CLI can auto-generate TypeScript types from your database schema. This lets you catch schema-related bugs at compile time:
# Generate types from local environmentnpx supabase gen types typescript --local > types/supabase.ts# Generate types from production (requires project ID)npx supabase gen types typescript \ --project-id YOUR_PROJECT_ID \ --schema public > types/supabase.ts
Using generated types gives you full compile-time safety:
import type { Database } from '@/types/supabase'type Tables = Database['public']['Tables']type Profile = Tables['profiles']['Row']type ProfileUpdate = Tables['profiles']['Update']// ✅ Type-checked at compile timeconst update: ProfileUpdate = { full_name: 'New Name', updated_at: new Date().toISOString(),}// ❌ Non-existent columns cause compile errors// const invalid: ProfileUpdate = { nonexistent_field: 'value' }
Useful package.json Scripts
{ "scripts": { "db:types": "supabase gen types typescript --local > types/supabase.ts", "db:migrate": "supabase db push", "db:reset": "supabase db reset", "db:seed": "npx supabase db seed" }}
With these scripts in place, Antigravity can suggest the right commands in the right order when you say "update types and implement the new feature."
Chapter 8: Production Deployment and Monitoring
Environment Variable Management
# .env.local (local development)NEXT_PUBLIC_SUPABASE_URL=http://127.0.0.1:54321NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-keySUPABASE_SERVICE_ROLE_KEY=your-service-role-key# For Vercel / Cloudflare Workers: set via dashboard or CLI
Performance Tuning
-- Add indexes for frequently queried columnscreate index idx_messages_conversation_id on public.messages (conversation_id, created_at desc);create index idx_projects_team_id on public.projects (team_id, status, created_at desc);-- Find slow queries with pg_stat_statementsselect query, mean_exec_time, callsfrom pg_stat_statementsorder by mean_exec_time desclimit 10;
One of the most important aspects of a production Supabase workflow is automating database migrations through CI/CD. With Antigravity, you can generate the entire GitHub Actions workflow just by describing your requirements.
Proper testing of Supabase interactions requires a local Supabase instance. Here's a pattern for setting up integration tests that actually run against a real database:
// tests/setup.tsimport { execSync } from 'child_process'import { createClient } from '@supabase/supabase-js'const SUPABASE_URL = 'http://127.0.0.1:54321'const SUPABASE_SERVICE_ROLE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY!export const testClient = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY)export async function resetDatabase() { // Reset to a clean state before each test suite execSync('npx supabase db reset --local', { stdio: 'inherit' })}export async function seedTestData() { // Insert known test data const { data: user } = await testClient.auth.admin.createUser({ email: 'test@example.com', password: 'testpassword123', email_confirm: true, }) if (user.user) { await testClient.from('profiles').insert({ id: user.user.id, username: 'testuser', full_name: 'Test User', }) } return { userId: user.user?.id }}
// tests/projects.test.tsimport { describe, it, expect, beforeAll } from 'vitest'import { testClient, seedTestData } from './setup'describe('Projects API', () => { let userId: string beforeAll(async () => { const seed = await seedTestData() userId = seed.userId! }) it('should return only projects for the authenticated user', async () => { // Create a team and project for the test user const { data: team } = await testClient .from('teams') .insert({ name: 'Test Team', owner_id: userId }) .select() .single() await testClient.from('projects').insert({ team_id: team!.id, name: 'Test Project', }) // Sign in as the test user const { data: session } = await testClient.auth.signInWithPassword({ email: 'test@example.com', password: 'testpassword123', }) // Create a client as the authenticated user (respects RLS) const userClient = createClient(SUPABASE_URL, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, { global: { headers: { Authorization: `Bearer ${session.session?.access_token}` }, }, }) const { data: projects } = await userClient.from('projects').select('*') // RLS should ensure only this user's projects are returned expect(projects).toHaveLength(1) expect(projects![0].name).toBe('Test Project') })})
This testing approach — running against a real local database rather than mocking — is the key to catching the kind of subtle RLS issues that only appear in production. Antigravity understands this philosophy and will suggest integration tests over mocks when writing Supabase-related test code.
Why RLS makes queries slow, and how to get the speed back
The four seconds from the opening had a simple cause: how often auth.uid() was being called.
A policy written as using (auth.uid() = user_id) reads like it fetches the current user ID once and compares. To the PostgreSQL planner, though, auth.uid() can be a per-row function call. Scan ten thousand rows and you parse the JWT ten thousand times. Four seconds starts to look reasonable.
Measure before you rewrite
Don't guess at the policy. Get the plan.
-- RLS doesn't apply to the table owner by default,-- so switch to a test role before measuringset local role authenticated;set local request.jwt.claims = '{"sub":"00000000-0000-0000-0000-000000000001"}';explain (analyze, buffers)select id, title, updated_atfrom public.projectswhere team_id = '11111111-1111-1111-1111-111111111111'order by updated_at desclimit 20;
Before the fix, my output contained this:
Seq Scan on projects (cost=... rows=9842 width=...) (actual time=0.12..3891.44 rows=20 loops=1)
Filter: ((auth.uid() = user_id) AND (team_id = '1111...'::uuid))
Rows Removed by Filter: 9822
Seq Scan plus Rows Removed by Filter: 9822 is the whole answer. No index in play, and the policy function running against every row.
Three fixes, in the order they pay off
Fix
How it's written
Measured improvement
When it applies
Hoist auth.uid() into an InitPlan
(select auth.uid()) = user_id
3,891ms → 214ms
Almost always. Do this first
Index the policy's columns
create index on projects (user_id)
214ms → 31ms
Once rows pass a few thousand
Move cross-table checks into a security definer function
is_team_member(), below
31ms → 9ms
When a policy reads another table
The first one adds a pair of parentheses and a keyword. To the planner, that changes everything: it becomes an uncorrelated subquery, gets hoisted into an InitPlan, and evaluates once per query instead of once per row. That single rewrite took 3,891ms to 214ms.
-- Beforecreate policy "own projects" on public.projects for select using (auth.uid() = user_id);-- After: hoisted into an InitPlan, evaluated oncedrop policy "own projects" on public.projects;create policy "own projects" on public.projects for select using ((select auth.uid()) = user_id);
The index step looks obvious, but it hides something easy to miss. Columns appearing in a policy's using clause are real filter conditions even though your application query never mentions them. user_id and team_id — the columns only the policy reads — are exactly the ones that need indexes.
Avoiding infinite recursion in multi-tenant policies
The third fix covers policies that read another table. Write a policy on team_members that itself queries team_members to check membership, and policy evaluation calls policy evaluation until Postgres gives up with infinite recursion detected in policy.
The way out is to move the lookup outside RLS.
-- Membership check sealed inside a security definer function.-- Queries inside the function don't inherit the caller's RLS, so no recursion.create or replace function public.is_team_member(target_team uuid)returns booleanlanguage sqlsecurity definerset search_path = public -- non-negotiable: omit it and you've opened a privilege escalation holestableas $$ select exists ( select 1 from public.team_members where team_id = target_team and user_id = (select auth.uid()) );$$;revoke all on function public.is_team_member(uuid) from public;grant execute on function public.is_team_member(uuid) to authenticated;create policy "team projects" on public.projects for select using (public.is_team_member(team_id));
security definer will hand out privileges you didn't intend if you're careless with it. That's why set search_path = public is always attached, and why the function is revoked from public before execute is granted back to authenticated alone. I've seen templates that skip those two lines. Skipping them quietly undoes the point of writing RLS at all.
When you have Antigravity generate policies, add two lines to your RULES.md: always write (select auth.uid()) inside policies, and route cross-table checks through a security definer function. The generated SQL will follow. Conventions you don't write down don't show up in the output.
Re-measure with the same query
-- AfterLimit (actual time=0.08..9.14 rows=20 loops=1) -> Index Scan using projects_team_id_updated_at_idx on projects (actual time=0.07..8.91 rows=20 loops=1) Filter: is_team_member(team_id)
Seq Scan became Index Scan, and Rows Removed by Filter is gone. That's the win. Only at this point does RLS stop being "safe but slow" and become safe and fast.
Know where the free tier actually breaks before you design around it
Supabase's free tier is generous enough for most side projects to start on. But if you don't know which wall you'll hit first, the bill arrives at a moment you didn't plan for. I enabled Realtime across every table once without thinking much about it, and the egress numbers surprised me.
What pushes you onto a paid plan usually isn't row count.
Metric
What triggers it first
Design-side response
Egress bandwidth
Serving Storage images at full resolution
Request a width via transform URLs; let the CDN cache them
Realtime connections
List screens holding subscriptions open
removeChannel on unmount; subscribe only to the rows you need
Edge Function invocations
Clients calling in small, frequent pieces
Batch into one call; push cacheable responses to the CDN
Database size
Logs and audit tables living in the same DB
Set a retention window and prune; archive long-term data elsewhere
The point of that table is the ordering. In most solo apps, egress and Realtime connections bite well before row count gets anywhere near the free limit. Which means the thing to optimize isn't the database — it's delivery and subscriptions.
For the decision itself, here's how I think about it:
Is the monthly cost reasonable against what the app earns, or against what my own hours are worth?
Is the paid plan cheaper than running my own servers would be?
Is the workaround code I'm writing to stay free now more complex than the feature it supports?
The third one matters most. The moment I start bending the design to protect a free tier, that's the signal it's time to pay. If roughly $25 a month covers auth, storage, and Realtime and keeps that maintenance off my desk, I'll pay it without much deliberation. For a solo developer, having my hands free is the more expensive resource.
The reverse holds too. With a few dozen users, there's no reason to move to a paid plan at all. Put the RLS design and subscription scoping from this article in place while you're still inside the free tier, and you won't be rebuilding under pressure when the users arrive.
Summary — a policy that works, versus one that's fast and safe
Supabase genuinely lightened a backend that was too heavy for one person to carry. Treating Auth, RLS, Edge Functions, Realtime, and Storage as a single foundation pays off more the longer you run it.
But the one thing I most wanted to get across is this: generated code, and policies written exactly as the samples show, don't automatically meet production's bar for speed or safety. A pair of parentheses around auth.uid() turns 3,891ms into 214ms. A forgotten set search_path quietly undoes the reason you wrote RLS. Neither gap is visible until you run it.
Antigravity honors the conventions you put in RULES.md — and only those. Which means handing it context isn't outsourcing your thinking. It's the work of putting your own design decisions into words.
For a next step, pick one policy in a project you're running right now, add set local role authenticated, and run explain (analyze, buffers) against it. If you see Seq Scan sitting next to Rows Removed by Filter, the first rewrite in this article will land immediately.
I'm still working these patterns out on my own projects, and I'd be glad if any of it saves you the half day I spent on that plan output.
Share
Thank You for Reading
Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.