Blog

How I Built a SaaS Demo Without Touching the Database

By Ian StrangFebruary 16, 2026

New users saw an empty dashboard. No stats. No leaderboards. No match history. Just blank screens with placeholder text.

It's like showing someone an empty restaurant menu and asking them to imagine the food. They can't visualize what the app will look like after months of use. The value proposition is invisible.

The product needed a demo mode. But demo modes are dangerous.

The Problem With Live Demos

The obvious approach: let new users browse a demo tenant with sample data. They can click around, see populated dashboards, understand the features.

The problems start immediately:

  1. Data isolation: Demo data must never leak into real tenants
  2. Write protection: Demo users shouldn't be able to modify demo data
  3. Authentication complexity: Demo mode needs different auth rules
  4. Tenant switching: Users need to move between demo and their real club
  5. Cache pollution: Demo data shouldn't pollute real data caches

Each problem has solutions, but the solutions interact. Tenant switching with different auth rules and cache isolation across both contexts is a recipe for bugs.

I tried designing a live demo system. The spec grew to hundreds of lines of edge cases. The implementation would touch authentication, tenant resolution, caching, and dozens of API routes. The attack surface was enormous.

The Simpler Solution

Instead of live database queries against a demo tenant, the solution was to export demo data once and serve it as static JSON files.

/src/data/example-club/
├── players.json
├── matches.json
├── seasons.json
├── stats.json
├── leaderboards.json
└── ... 8 more files

The data comes from the founder's club (Berko TNF), which has 10+ years of match history. Real data, anonymized for privacy. Phone numbers and emails are sanitized. Player names are kept because they're already public in match reports.

When a user enters demo mode:

  1. A cookie is set: example_mode=true
  2. API requests are intercepted by a proxy route
  3. The proxy serves static JSON instead of querying the database
  4. Write operations return friendly error messages

No database queries. No tenant switching. No cache pollution. The demo is completely isolated from the production system.

The Eligibility System

Not every user should see the demo option. A club with 500 matches doesn't need to see what a mature club looks like — they are a mature club.

Eligibility rules:

  • Club has zero matches AND is less than 30 days old → Demo available
  • First match created OR 30 days pass → Demo option disappears permanently

This is checked server-side. The cookie alone doesn't grant access; the database confirms eligibility. A stale cookie from an old session can't bypass the check.

async function isEligibleForDemo(tenantId: string): Promise<boolean> {
  const tenant = await getTenant(tenantId);
  const matchCount = await getMatchCount(tenantId);
  const daysSinceCreation = daysBetween(tenant.created_at, new Date());
  
  return matchCount === 0 && daysSinceCreation < 30;
}

The UX Decisions

"View example club" not "Demo mode"

Framing matters. "Demo" implies fake, limited, not-the-real-thing. "View example club" implies aspirational — this is what your club could look like.

Remove, don't disable

Interactive elements in demo mode are removed entirely, not greyed out. A greyed-out "Book Match" button raises questions: "Why can't I click this? Is it broken?" An absent button raises no questions.

Persistent banner

While in demo mode, a banner reminds users they're viewing example data. The exit button is prominent. Users always know where they are.

Clean exit

Exiting demo mode invalidates React Query caches and returns to the user's real club. No stale demo data appears in their actual dashboard.

The Implementation

The proxy route intercepts API requests in demo mode:

// /api/example-club/[...slug]/route.ts
export async function GET(request: NextRequest) {
  // Verify demo mode is active and user is eligible
  if (!await isInDemoMode(request)) {
    return NextResponse.json({ error: 'Not in demo mode' }, { status: 403 });
  }
  
  // Serve static JSON file
  const slug = getSlug(request);
  const data = await readJsonFile(`/data/example-club/${slug}.json`);
  return NextResponse.json(data);
}

export async function POST(request: NextRequest) {
  // All writes return friendly error
  return NextResponse.json({ 
    error: 'This is example data. Changes are not saved.',
    success: false 
  });
}

The frontend doesn't know it's in demo mode. It makes the same API calls. The routing layer handles the interception.

What the AI Got Wrong

The AI initially designed a live tenant switching system. It was elegant but complex — different auth rules, cache isolation, tenant context switching. Each piece worked in isolation but the interactions were fragile.

I pushed back. "What's the simplest possible demo mode?" The AI proposed static JSON exports. I asked about the tradeoffs. The AI listed them: data gets stale, can't show real-time features, requires manual updates. All acceptable for our use case.

The AI also wanted to grey out interactive elements. I had to enforce the "remove, don't disable" principle explicitly. Greyed-out elements create confusion; absent elements don't.

The Broader Pattern

I talk more about the overall approach in How I Actually Vibe Code. The demo mode illustrates a key principle: the simplest solution that works is usually the best solution.

The multi-tenancy architecture made demo mode possible. Tenant isolation was already solved; demo mode just needed to not break it.

The spec-driven approach helped here too. Writing the spec for live tenant switching revealed the complexity. Writing the spec for static JSON exports revealed the simplicity. The specs made the tradeoff visible before any code was written.

The Outcome

New users can now see what a mature club looks like:

  • 10+ years of match history
  • Populated leaderboards and hall of fame
  • Season statistics and player profiles
  • Team balancing visualizations

They click "View example club," explore for a few minutes, understand the value, and return to their empty club with a clear picture of what they're building toward.

The demo mode spec is 1,780 lines. The implementation is a few hundred lines of code. The static JSON files total about 500KB. No database queries. No tenant switching. No cache pollution.

Sometimes the best architecture is the one you don't build.

Series Navigation