Supabase vs. AWS: What Five Services Replace One Dependency
Supabase bundles Postgres, auth, storage and atomic RPCs behind one SDK. Mapped against this app's real files, here's what RDS, Cognito, S3 and Lambda would cost to assemble instead.
Supabase is one platform — Postgres, auth, storage and functions behind a single client library. AWS is not a backend at all; it's a catalog you assemble one from, service by service. This storefront runs on Supabase, so the honest comparison isn't "which is better" — it's what you'd actually have to wire up on AWS to replace the five things this app gets from one dependency, counted against the real files that use each one.
The short answer
Supabase bundles a managed Postgres database, an auth service, private file storage and a way to run atomic server-side logic, all reachable from one SDK with one set of credentials. AWS gives you the components to build the same thing yourself — RDS, Cognito, S3, Lambda — each with its own console, its own IAM policy shape, and no wiring between them until you write it. Pick Supabase when you want that wiring done for you and can live inside its opinions. Pick AWS when you're already standardized on it, need a service Supabase doesn't offer, or the assembly cost buys you configuration Supabase's opinions don't allow.
What each side actually is
| Supabase (this app) | The AWS assembly | |
|---|---|---|
| Database | Managed Postgres, one connection string | RDS for PostgreSQL — you provision instance size, patching window, backup retention |
| Auth | Bundled auth service, JWTs verified with getClaims() | Cognito User Pool issues JWTs; verifying them in a Route Handler is your own middleware |
| Authorization | Row-level security, evaluated by Postgres itself | RLS is still available on RDS Postgres, but nothing wires a Cognito claim into a policy automatically — you write that mapping |
| File storage | A private bucket with server-issued signed URLs | S3, plus either presigned URLs from your own code or a CloudFront signed-URL setup |
| Atomic server logic | A SQL function, SECURITY DEFINER, called over the same connection | A Lambda function, its own deploy unit, calling back into RDS for the transaction |
| Client surface | One SDK: @supabase/supabase-js / @supabase/ssr | One SDK per service: @aws-sdk/client-rds-data, amazon-cognito-identity-js, @aws-sdk/client-s3, plus the Lambda runtime itself |
Every row on the AWS side is a real, capable service. None of them talks to the others by default.
What this app's five Supabase pieces actually do
Counted in this checkout, not estimated.
Database — 8 migrations, one schema. supabase/migrations/ holds 8 files (0001_commerce_init.sql through 0008_refund_requests.sql), each one a plain SQL migration against a single Postgres instance. On RDS, this is the same SQL — Postgres is Postgres — but provisioning, patch scheduling, and connection pooling are now a console you configure yourself, not a bucket item on the Supabase dashboard.
Auth — three clients, one verification rule.
src/lib/supabase/client.ts — browser client (anon key), identity only
src/lib/supabase/server.ts — server client, bound to request cookies, every real read
src/lib/supabase/admin.ts — service-role client, bypasses RLS, server-only
src/proxy.ts — refreshes the session every request, via getClaims()
src/proxy.ts is 42 lines. It calls supabase.auth.getClaims() — never getSession(), because getSession() trusts a cookie without verifying the JWT against Supabase's servers, and getClaims() doesn't. On Cognito, the equivalent exists (a JWKS-verified token), but nothing hands it to you pre-wired into a Next.js proxy — the verification middleware is code you write once and then own.
Authorization — 6 row-level-security policies, all for select:
-- supabase/migrations/0001_commerce_init.sql
create policy "own profile" on public.profiles for select using ((select auth.uid()) = id);
create policy "own orders" on public.orders for select using ((select auth.uid()) = user_id);
create policy "own slots" on public.entitlement_slots for select using ((select auth.uid()) = user_id);
create policy "own downloads" on public.download_events for select using ((select auth.uid()) = user_id);
-- supabase/migrations/0008_refund_requests.sql
create policy "own deliveries" on public.download_deliveries for select using ((select auth.uid()) = user_id);
create policy "own refund requests" on public.refund_requests for select using ((select auth.uid()) = user_id);
auth.uid() reads straight out of the verified JWT Supabase's Postgres extension already has on the connection. RDS Postgres supports the identical create policy ... using (...) syntax — RLS is a Postgres feature, not a Supabase one — but auth.uid() doesn't exist there. You'd write a Lambda authorizer that takes the Cognito claim, sets it as a Postgres session variable per connection (SET LOCAL app.user_id = ...), and reference that variable in the policy instead. Same capability, one more layer you own.
Storage — a private bucket, a signed URL, and nothing else touching it. supabase/migrations/0001_commerce_init.sql documents the releases bucket as private, and src/app/api/download/route.ts is the only code path that calls .storage.from("releases").createSignedUrl(path, SIGNED_URL_TTL_SECONDS, ...) — one call, a 60-second TTL, after authorizeDownload() in src/lib/download.ts has already confirmed the caller owns the edition. On S3 this is the same idea — a presigned URL, or a CloudFront signed URL if you want edge caching — but it's your own SDK call, your own bucket policy, and (if you want the edge-cached version) a key-pair setup in CloudFront that Supabase's dashboard just doesn't require you to think about.
Atomic writes — 3 SECURITY DEFINER functions, one job each:
create_order_with_slots() — supabase/migrations/0003_commerce_rpcs.sql
refund_order() — supabase/migrations/0003_commerce_rpcs.sql (redefined in 0008)
record_download_within_limit() — supabase/migrations/0007_atomic_download_rate_limit.sql
Called from exactly three places — src/lib/lemonsqueezy/webhookDb.ts (twice) and src/app/api/download/route.ts — each over client.rpc("function_name", { ... }), the same connection as every other query. Postgres runs the function body inside one transaction with the function's own privileges, search_path pinned, EXECUTE revoked from anon/authenticated so only server code can call it. The AWS equivalent is a Lambda function that opens its own connection to RDS, opens its own transaction, and either commits or rolls back — the same guarantee, but now split across two systems (the RDS connection pool and Lambda's cold-start lifecycle) instead of living inside Postgres itself.
The one line that's actually the whole argument
Every AWS row above is doable. None of them is wrong. What's different is where the wiring lives: on Supabase, "verify who's asking" and "let Postgres check if they own the row" are two steps inside one system, connected by one JWT that Postgres itself can read (auth.uid()). On AWS, that's Cognito issuing a token, a Lambda authorizer or API Gateway custom authorizer checking it, and a session variable smuggled into an RDS connection so a hand-written RLS policy has something to compare against. Five services, four different consoles, and the join between "authenticated" and "authorized" is code you own instead of a Postgres built-in.
That's the trade, stated plainly: Supabase's assembly cost is paid once, by Supabase, before you ever open the dashboard. AWS's assembly cost is paid by whoever builds this app — every time, on every project, unless a template internally reproduces getClaims() → auth.uid() for them.
Where AWS actually wins the argument
None of this is an argument that AWS is worse — it's an argument that Supabase is a pre-assembled form of exactly these AWS services, and pre-assembly has a price on the other side of it: opinions you don't control. AWS wins when you're already running production workloads there and adding a sixth service costs less than a new vendor relationship; when you need something Supabase's managed layer doesn't expose (VPC peering into an existing private network, a specific RDS engine version or extension, Aurora's multi-region replication); or when compliance requires infrastructure your organization already has SOC reports and audit trails for. None of those are "Supabase can't do it" — they're "we've already paid AWS's assembly cost for other reasons, so paying it again here is free."
Mistakes and troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
auth.uid() returns null in a Postgres policy on Supabase | The connection isn't carrying a verified JWT — usually a skipped session refresh | Confirm the proxy or session-refresh step ran with getClaims(), not a raw cookie read |
| Migrating to RDS, RLS policies stop matching any row | auth.uid() is a Supabase-provided function; plain RDS Postgres has no equivalent | Set a session variable from your own auth layer (SET LOCAL app.user_id = '<id>') and rewrite policies against it |
| A Lambda authorizer adds noticeable latency to every request | Cold starts on infrequently-invoked authorizer functions | Provisioned concurrency, or cache the authorization decision at the API Gateway layer |
| Signed URL works locally, 403s from CloudFront in production | Presigned S3 URLs and CloudFront signed URLs use different signing keys and aren't interchangeable | Decide up front whether you need edge caching (CloudFront) or a direct-to-S3 URL is enough, and sign for that path specifically |
A SECURITY DEFINER function ported to a Lambda loses its transaction guarantee | Lambda's connection to RDS is a separate transaction boundary from whatever called it | Wrap the whole operation in an explicit BEGIN/COMMIT inside the Lambda, or use RDS Data API's transaction support |
Frequently asked questions
Is Supabase just AWS with a nicer dashboard? Not quite — Supabase runs its own infrastructure (not resold AWS), but the comparison that matters isn't the hosting layer, it's the assembly. Supabase pre-wires database, auth and storage into one system with one credential model; AWS gives you the pieces and lets you decide how they connect.
Can you self-host Supabase and get AWS-level control? Yes — Supabase is open-source and its self-hosting guide runs the same stack (Postgres, GoTrue for auth, Storage API) on your own infrastructure, AWS included. That trades the managed dashboard for full control, which is a real middle option between the two poles this comparison describes.
Does RLS on RDS Postgres do anything without Cognito wired in?
Yes — CREATE POLICY is core Postgres, available on any RDS instance regardless of auth provider. What RDS doesn't give you for free is a function like auth.uid() that reads an authenticated user's ID straight off the connection; you provide that plumbing yourself.
Why does this app call getClaims() instead of getSession()?
getSession() reads the session cookie without revalidating it against Supabase's auth server, so a forged or stale cookie can pass. getClaims() verifies the JWT's signature and expiry before trusting it — the same distinction as validating a Cognito token's signature rather than trusting whatever sub claim a client sends.
Templates in this post
ASoc Cover markets a modern insurance brand around a two-minute-quote hero, four cover types, and a live claim-paid dashboard mock. ASoc Echo is an AI customer-support landing page with a live chat-widget preview, a visual flow-builder section, and a conversation-analytics panel. ASoc Edge markets an applied-AI agency around a unified "AI control room" dashboard and an integrations row spanning Messenger, Slack and OpenAI.
Browse the full sets: Next.js landing page templates, Tailwind landing page templates.
