People want to click around a SaaS before they pay for it. For a multi-tenant app, the simplest demo is a shared login printed on the sign-in page, with a sample team already set up. It is also the easiest kind of demo to break, usually within its first day. The kit ships a demo mode built around those failures. You can use the same setup for your own product.
What goes wrong with a shared login
- Someone changes the password, and every visitor after them is locked out.
- Someone turns on two-step sign-in with their own phone. Same result.
- Someone deletes the sample team or transfers it to themselves.
- Someone invites real email addresses, and your demo sends mail to strangers.
- Data piles up: renamed teams, removed members, test subscriptions, hundreds of throwaway accounts.
Hiding the buttons does not solve any of these. The API is still there, and anyone with the public key can call it.
A separate deployment and database
The demo is a second deployment of the same code with NEXT_PUBLIC_DEMO_MODE=true, connected to its own Supabase project. Nothing a visitor does can reach your real users, because they are in a different database.
The demo database gets the normal migrations but not the development seed, which contains a published admin login. Sample data comes from apps/web/supabase/demo/demo.sql instead: a shared user, a team called Acme Inc with two more members, two pending invitations and a few notifications.
Guards in the database
demo.sql adds triggers that refuse the dangerous changes for the demo users and the demo team: email, password, two-step sign-in, linked sign-in methods, deleting the account, deleting the team, transferring ownership.
The triggers decide who may make those changes by looking at the database login of the connection (session_user). Only postgres and supabase_admin, which means you in the SQL editor or the scheduled job, pass. The Auth server and the API connect with their own roles, so a request through the app or straight to the API is refused, whatever the browser sends.
The app hides the same settings for the shared login and explains why. That is for the visitor's benefit; the protection is the trigger.
Emails are off
- The app's own emails (invitations, one-time codes, the contact form) go through
MAILER_PROVIDER=log, a mailer that sends nothing and logs only the recipient's domain and the subject. - Supabase Auth emails (password reset, magic links) stay on Supabase's built-in sender, with no custom SMTP. That sender only delivers to members of your Supabase organisation, so the reset page cannot be used to email strangers.
The hourly reset
A pg_cron job runs demo.reset() every hour. It deletes every team and every visitor account, restores the demo users with their password and names, removes any two-step factor, and rebuilds Acme Inc with the same id, members, invitations and notifications.
That function would be dangerous in the wrong database, so it does nothing unless demo.settings.reset_enabled is true, a value you set by hand on the demo project. Applying demo.sql to your production database by mistake cannot wipe it.
One gap is documented rather than hidden: uploaded pictures are unlinked from accounts but stay in storage, because Supabase blocks deleting storage rows from SQL. Clean the bucket now and then.
Tested like the rest
pnpm --filter web supabase:demo:test applies demo.sql and runs its tests in one transaction against your local database, then rolls everything back. The tests make a mess on purpose (a visitor team, a renamed Acme, a removed member, deleted invitations, a two-step factor, a changed password, a test-mode customer) and check that the reset cleans it up. They also try every guard as the database owner and as the API roles. A second file, demo.rls.test.sql, attacks through the real login roles the API uses.
Using it for your product
The setup guide is apps/web/supabase/demo/README.md, also on the Live demo docs page: a Supabase project, a second deployment with a handful of environment variables, and a link from your sales site.
