Ship a Live Demo of Your Supabase App That Runs Entirely in the Browser
Every SaaS landing page wants a "Try the demo" button. And every developer who's built one knows the problems that come with it: A shared demo account that strangers fill with junk (or worse) within a day A cron job to
Every SaaS landing page wants a "Try the demo" button. And every developer who's built one knows the problems that come with it:
- A shared demo account that strangers fill with junk (or worse) within a day
- A cron job to reset the demo database every night
- Real backend costs for people who will never sign up
- Rate limits, abuse, and the occasional visitor who deletes everything
There's another way. Run the backend inside the visitor's browser. Each visitor gets their own private copy of your app's database, built from your migrations and seed data. Nothing is shared, nothing needs resetting, and the demo is just static files.
This guide does that for a Supabase app using tinbase, an open-source Supabase-compatible backend that can run in the browser on PGlite (Postgres compiled to WebAssembly).
tinbase is alpha. It covers a large part of the
supabase-jssurface, but not everything; check the known gaps in the README before relying on a specific feature for your demo.
How does it work?
Your app already talks to Supabase through supabase-js. That client accepts a custom fetch. In demo mode, instead of sending requests over the network, you hand them to a backend running in the same page:
your UI β supabase-js β backend.fetch β Postgres (PGlite, in the browser)
Your components don't change. Only the client setup does.
Step 1: Make the client switchable
Here's a Vite example. In normal mode, the app uses your real Supabase project. In demo mode, it starts an in-browser backend.
// src/lib/supabase.ts
import { createClient, type SupabaseClient } from '@supabase/supabase-js';
const DEMO = import.meta.env.VITE_DEMO === 'true';
export async function getSupabase(): Promise<SupabaseClient> {
if (!DEMO) {
return createClient(
import.meta.env.VITE_SUPABASE_URL,
import.meta.env.VITE_SUPABASE_KEY
);
}
const { createDemoBackend } = await import('./demo-backend');
const backend = await createDemoBackend();
return createClient('http://localhost', backend.anonKey, {
global: { fetch: (input, init) => backend.fetch(new Request(input, init)) },
});
}
The dynamic import() matters: visitors to your real app never download the demo backend.
Step 2: Build the demo backend from your migrations
Vite can import SQL files as strings with ?raw, so the demo uses the exact same migrations as production:
// src/lib/demo-backend.ts
import { createBackend } from 'tinbase';
import init from '../../supabase/migrations/20261001000000_init.sql?raw';
import projects from '../../supabase/migrations/20261005000000_projects.sql?raw';
import demoSeed from '../../supabase/demo-seed.sql?raw';
export function createDemoBackend() {
return createBackend({
dataDir: 'idb://my-app-demo-v1', // persist in IndexedDB between visits
migrations: [
{ name: '20261001000000_init', sql: init },
{ name: '20261005000000_projects', sql: projects },
{ name: '99990000000000_demo_seed', sql: demoSeed },
],
});
}
A few choices here:
- The demo seed runs as the last migration, so every new visitor starts with the same realistic sample data.
-
dataDir: 'idb://...'keeps the visitor's changes in IndexedDB, so the demo survives a page refresh. Bump the name (-v2) when you change the seed, and returning visitors get a fresh copy. - If you'd rather every visit start clean, leave persistence out. Check the README for the current in-memory option.
Step 3: Sign the visitor in automatically
A demo shouldn't ask for an email. Anonymous sign-in gives each visitor a real authenticated user, so your RLS policies work exactly as they do in production:
const supabase = await getSupabase();
if (import.meta.env.VITE_DEMO === 'true') {
const { data } = await supabase.auth.getSession();
if (!data.session) await supabase.auth.signInAnonymously();
}
If your seed data needs to belong to the visitor, assign it after sign-in, or write your seed to use rows that any authenticated user can read.
Step 4: Build and host it as static files
VITE_DEMO=true npm run build
Deploy the output folder to any static host (GitHub Pages, Netlify, Vercel, Cloudflare Pages) at something like demo.yourapp.com. There's no server, no database, and no bill that grows with demo traffic.
Step 5: Make it obvious it's a demo
Visitors should know their data stays on their device and isn't saved to a real account. Add a small banner:
{import.meta.env.VITE_DEMO === 'true' && (
<div className="demo-banner">
Demo mode: everything runs in your browser and stays on this device.
<a href="https://yourapp.com/signup">Create a real account</a>
</div>
)}
That banner is also your conversion point: someone who just spent five minutes in a working app is a much warmer lead than someone who watched a video.
What are the trade-offs?
Be honest with yourself about these before shipping:
- Download size. WASM Postgres adds several megabytes to the demo build. Lazy-load it, and show a short loading state ("Setting up your demoβ¦").
- Memory. WASM Postgres is memory-hungry. Test on an older phone and a low-end laptop.
- Feature coverage. Features your app uses that tinbase doesn't support yet (see the README's known gaps, such as MFA, phone auth, or image transforms) need to be hidden or stubbed in demo mode.
- No server-side logic. Anything that depends on third-party APIs, emails, or payments should be faked or disabled in the demo.
- Not for production. This is a demo and prototyping technique. Your real app keeps using your real Supabase project.
When is this a good fit?
- Dashboards, CRUD tools, internal-tool templates, and productivity apps where the value is obvious once someone clicks around
- Template or boilerplate products where buyers want to try before they buy
- Conference demos and workshops where Wi-Fi can't be trusted
It's a weaker fit for apps whose value depends on other users (social, messaging) or heavy server-side processing.
Wrapping up
A browser-only demo removes the worst parts of "Try the demo": no shared account to vandalize, no nightly reset job, and no backend cost per visitor. Your migrations build the database, anonymous auth signs the visitor in, and your real RLS policies keep working.
Source, benchmarks, and known gaps: tinbase on GitHub. Have you built a "try it now" demo for your app? How did you handle the shared-data problem? Tell me in the comments.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.