Supabase gives you Postgres, auth, storage and realtime in an afternoon, which is exactly why the hard problems arrive later. The policy that silently returns an empty array, the migration that ran on staging and not on production, the realtime channel holding a connection per open tab: those decide whether the stack holds. Here is how we approach them, and where Supabase is the wrong answer.
RLS is the entire security boundary, and it fails silently
The browser gets a public key by design, the publishable key in the current format or the anon key in the legacy one, and with it the client talks straight to Postgres. So row level security is not hardening you add later. It is the only thing between a curious visitor and a full read of your tables. The service role key is the mirror image: it bypasses RLS completely, so it belongs on the server and never in a client component or a NEXT_PUBLIC_ variable.
The failure modes repeat across the projects we get called into. A policy exists for SELECT and nothing was written for UPDATE or DELETE, because the feature worked in testing. USING and WITH CHECK get confused, so a user can push a row into another tenant even though they cannot read it back. Ownership resolves through a join across two tables, the planner cannot inline it, and every query on a large table degrades.
The nastiest property is that RLS fails without an error. A wrong policy returns zero rows rather than permission denied, so the bug reaches you as "the dashboard is empty for some users" days after deploy, looking like a frontend problem. We write policies in the migration that creates the table, never afterwards, and test them connected as anon, as a user of tenant A and as a user of tenant B, asserting what must not be visible as well as what must. Views need security_invoker or they run with the definer's rights and bypass the policies underneath, and any SECURITY DEFINER function needs its search_path pinned. Storage is the same story: objects are rows and need policies of their own.
Auth flows: the parts that only surface after launch
Supabase Auth owns auth.users and you do not control that table. Anything you want to store about a person belongs in your own profiles table, keyed by the same id, populated by a trigger on signup, with the foreign key set to cascade so a deleted account leaves no orphan rows. Retrofitting that is the most common rework we do on inherited projects.
In a Next.js App Router codebase, the mistake that actually matters is trusting getSession on the server. It decodes the cookie without asking the auth server whether the token is still valid, so a revoked user keeps passing your checks. getUser makes the round trip and verifies. Use it wherever a server-side decision depends on identity.
The rest is environment discipline. Redirect URLs have to be allowed per environment or OAuth quietly drops users on the wrong origin. Mobile deep links need their own scheme registered. Custom claims belong in a JWT hook so a policy can read a role without a table lookup on every request, though a claim added today only applies once existing tokens refresh, which leaves a window where both token shapes are live. Email is the last trap: the built-in SMTP is rate limited and meant for development, so budget for a real provider (Postmark, Resend, SES) with its own usage-based cost and its own DKIM and SPF records before you open public signups.
Migrations, environment parity and local development
The Supabase dashboard is a good way to explore a database and a bad way to change one. Every click that adds a column creates state that exists in production and nowhere in your repository, and three months later nobody can rebuild the schema from scratch. We work the other way round: the CLI generates a migration file, the file goes through a pull request like any other code, and it is applied to environments in order. If someone did edit through the dashboard, supabase db diff surfaces it and it becomes a migration before anything else happens.
Local parity is what makes that sustainable. The CLI runs the stack in Docker, Postgres plus auth, storage, realtime and the Deno runtime for edge functions, so you can reset the database, replay every migration and reload seed data in under a minute. Bugs get reproduced against real Postgres instead of a mock. Two things still differ from production: plan-level features such as read replicas or point-in-time recovery, and edge function cold starts, which you never feel locally.
For schema changes on a live table, forward-only migrations with expand and contract: add the column, backfill in batches, write to both, switch reads, drop the old one in a later release. Renaming a column in a single migration is how an app goes down for the length of a deploy. Adding an index on a busy table wants CREATE INDEX CONCURRENTLY, which will not run inside the transaction most migration tools wrap around your file.
Where the latency and the bill come from: pooling, realtime, portability
Supabase scales further than most founders expect, and it has edges worth knowing before you reach them. Connections are the first. Postgres opens a process per connection and serverless functions open many, so anything on Vercel or Lambda goes through the pooler in transaction mode. That carries a consequence people meet in production rather than in the docs: transaction mode keeps no session state, so prepared statements, LISTEN and session level advisory locks behave differently, and some ORMs need an explicit flag to stop preparing statements at all. Direct connections are IPv6 by default, so a runtime without IPv6 either goes through the pooler or pays for the IPv4 add-on.
Realtime is the second. Postgres changes stream out of a replication slot and row level security is evaluated per message per subscriber. A thousand open tabs subscribed to a busy table means a thousand policy evaluations per write, and it shows up as latency before it shows up on an invoice. Broadcast and presence are far cheaper for high-frequency updates such as cursors, and one channel with server-side fan out beats a subscription per row.
The reassuring part is portability. It is real Postgres, so pg_dump and restore work, and the surrounding components are open source and self-hostable. Leaving later means rewriting auth and storage integration, not your data layer. The expensive thing to replace, your schema and your queries, is the part that is standard.
When Supabase is the wrong choice, and when not to hire us
Supabase is the wrong tool more often than its advocates admit. Analytical workloads are the clearest case: if the product is dashboards over hundreds of millions of rows, a column store such as ClickHouse or BigQuery answers in the time Postgres spends planning. Heavy background processing with retries, fan out and long-running jobs wants a real queue and workers, not edge functions built to return quickly. If your data is genuinely document shaped and you need offline sync on mobile, the Firestore client SDK still does things Supabase does not. And if your team already runs .NET or Spring behind an established identity provider, adding Supabase Auth gives you two sources of truth about who a user is, which is worse than either alone.
If what you need is a brochure site, a booking form or a small shop, none of this applies. Squarespace, Wix, Shopify or Webflow will be faster to launch, and their subscription plans are published on their own sites (the figures move, so check them there): for that kind of project a hosted plan and a template beat custom code on both launch time and running cost.
Do not hire us either if you want daily calls, a project manager between you and the developer, someone on site, or a 24/7 on-call rota. We are two people in Dijon: Robin Monteiro on the code, Florian Loppion on the marketing side. We do not do meetings at all. We also decline work on codebases where we cannot get real access to the Supabase project and the repository, because inferring a schema from screenshots wastes your money.
How a two-person, written-only team works with clients abroad
Written and asynchronous is the whole method, and for a client abroad it is the part that makes distance stop mattering. Everything happens by email, we reply within 24 business hours, and there are no calls, no video, no scheduled slots, not even as an option. A founder in California never takes a 3am call to unblock a migration. A team in Bangalore does not wait for a European morning for an answer that could have been written the night before. Nothing here needs two people awake at the same time.
The second benefit is the archive. Why a table was denormalized, why one function is SECURITY DEFINER, which index was added and what it cost on writes: it sits in a thread you can search a year later, when the person who asked has moved on.
What you have to do differently is real, so it is worth stating. Write the requirement instead of talking it through, and front load the detail: the failing query, the exact error, the row that should have been visible and was not. Batch your questions rather than sending one an hour. Give us access to a staging project instead of describing the bug. In exchange you get the people writing the code, with no account manager layer, and you own everything at the end: repository, Supabase organization, domain, accounts. Pricing is on request, with no mandatory retainer. Recent production work for French SMEs includes Chouchou Ribeyre, Au Petit Detail, LB Athletic, Mediavocats and Vectosolve.