[TERMINAL · SKILLS]
> mounting /skills...
> indexing skill manifests...
> linking agents: claude · codex · gemini · cursor
> ready.
[░░░░░░░░░░░░░░░░░░░░░░░░░░░░] 0%
Use Cases/Build an Instant SaaS Demo Environment

Build an Instant SaaS Demo Environment

Build one-click demo environments for prospects — isolated tenant with seed data in under 5 seconds, guided tour, engagement tracking, and upgrade CTA on expiry.

Development#orm#database#typescript#migrations#schema
Works with:claude-codeopenai-codexgemini-clicursor

Skills stack · 2 skills

Avg quality 100/100·All SAFE
$

The Problem

Your sales engineer spends 2 hours setting up a custom demo for every prospect. Half of them don't show up. The other half want to "try it themselves" after the call, but you don't have a self-serve option — so they bounce.

Goal: One-click demo provisioning. Prospect gets an isolated environment with realistic seed data in <5 seconds, a guided tour, and you see exactly which features they explored.


Who This Is For

SaaS sales engineer replacing manual demo setup. You want to send a prospect a link that spins up a full demo in their browser, tracks engagement, and converts them to a signup when the demo expires.


Step 1: Prisma Schema

prisma
// prisma/schema.prisma
model DemoSession {
  id           String    @id @default(cuid())
  token        String    @unique @default(cuid())  // URL token
  prospectEmail String?
  prospectName  String?
  company       String?

  // Tenant isolation
  tenantId     String    @unique  // one tenant per demo

  status       String    @default("active")  // active | expired | converted
  expiresAt    DateTime
  convertedAt  DateTime?
  createdAt    DateTime  @default(now())

  tenant  DemoTenant @relation(fields: [tenantId], references: [id])
  events  DemoEvent[]
}

model DemoTenant {
  id        String   @id @default(cuid())
  name      String   @default("Demo Company")
  createdAt DateTime @default(now())

  session DemoSession?
  // All demo data is scoped to this tenant
  users   DemoUser[]
  projects DemoProject[]
}

model DemoUser {
  id       String @id @default(cuid())
  tenantId String
  name     String
  email    String
  role     String
  avatar   String?

  tenant DemoTenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
}

model DemoProject {
  id          String @id @default(cuid())
  tenantId    String
  name        String
  status      String
  description String?
  createdAt   DateTime @default(now())

  tenant DemoTenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
}

model DemoEvent {
  id          String   @id @default(cuid())
  sessionId   String
  event       String   // "feature.viewed", "project.opened", "invite.clicked"
  feature     String?
  metadata    String?  @db.Text  // JSON
  createdAt   DateTime @default(now())

  session DemoSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)

  @@index([sessionId, createdAt])
}

Step 2: Demo Provisioning API

typescript
// app/api/demo/create/route.ts
import { prisma } from "@/lib/prisma";
import { seedDemoTenant } from "@/lib/demo/seeder";

export async function POST(request: Request) {
  const body = await request.json().catch(() => ({}));
  const { email, name, company } = body;

  // Create isolated tenant + demo session in a transaction
  const session = await prisma.$transaction(async (tx) => {
    const tenant = await tx.demoTenant.create({
      data: { name: company ? `${company} Demo` : "Your Company Demo" },
    });

    const demoSession = await tx.demoSession.create({
      data: {
        tenantId: tenant.id,
        prospectEmail: email,
        prospectName: name,
        company,
        expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24h
      },
    });

    return demoSession;
  });

  // Seed realistic demo data (outside transaction for performance)
  await seedDemoTenant(session.tenantId);

  return Response.json({
    url: `${process.env.APP_URL}/demo/${session.token}`,
    expiresAt: session.expiresAt,
  });
}

Step 3: Demo Seeder — Realistic Data in <5s

typescript
// lib/demo/seeder.ts
import { prisma } from "../prisma";

const SEED_USERS = [
  { name: "Alice Johnson", email: "alice@demo.example", role: "admin", avatar: "https://i.pravatar.cc/150?u=alice" },
  { name: "Bob Chen", email: "bob@demo.example", role: "member", avatar: "https://i.pravatar.cc/150?u=bob" },
  { name: "Carol White", email: "carol@demo.example", role: "viewer", avatar: "https://i.pravatar.cc/150?u=carol" },
];

const SEED_PROJECTS = [
  { name: "Q1 Marketing Campaign", status: "active", description: "Driving awareness for the spring launch" },
  { name: "Product Redesign", status: "completed", description: "UX overhaul shipped in Feb" },
  { name: "Customer Onboarding Flow", status: "active", description: "Reduce time-to-value from 7 days to 2" },
  { name: "API Integration", status: "planning", description: "Connect with Salesforce and HubSpot" },
  { name: "Analytics Dashboard", status: "active", description: "Real-time metrics for the exec team" },
];

export async function seedDemoTenant(tenantId: string): Promise<void> {
  // Use createMany for speed — bulk insert, not N queries
  await Promise.all([
    prisma.demoUser.createMany({
      data: SEED_USERS.map((u) => ({ ...u, tenantId })),
    }),
    prisma.demoProject.createMany({
      data: SEED_PROJECTS.map((p) => ({
        ...p,
        tenantId,
        createdAt: randomPastDate(30), // randomize dates for realism
      })),
    }),
  ]);
}

function randomPastDate(maxDaysAgo: number): Date {
  const daysAgo = Math.floor(Math.random() * maxDaysAgo);
  return new Date(Date.now() - daysAgo * 86400000);
}

Step 4: Demo Session Middleware

typescript
// middleware.ts — inject demo context
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";

export async function middleware(request: NextRequest) {
  const demoToken = request.cookies.get("demo_token")?.value;
  const pathToken = request.nextUrl.pathname.match(/^\/demo\/([^/]+)/)?.[1];

  const token = pathToken ?? demoToken;

  if (token) {
    // Let the layout resolve the tenant from the token
    const response = NextResponse.next();
    response.headers.set("x-demo-token", token);

    // Set cookie so subsequent navigations stay in demo context
    if (pathToken && !demoToken) {
      response.cookies.set("demo_token", pathToken, {
        httpOnly: true,
        maxAge: 24 * 3600,
        sameSite: "lax",
      });
    }

    return response;
  }

  return NextResponse.next();
}

Step 5: Engagement Tracking

typescript
// lib/demo/track.ts
"use server";

import { prisma } from "../prisma";
import { cookies, headers } from "next/headers";

export async function trackDemoEvent(
  event: string,
  feature?: string,
  metadata?: Record<string, unknown>
): Promise<void> {
  const token = cookies().get("demo_token")?.value;
  if (!token) return;

  const session = await prisma.demoSession.findUnique({
    where: { token },
    select: { id: true, status: true, expiresAt: true },
  });

  if (!session || session.status !== "active") return;
  if (session.expiresAt < new Date()) {
    // Mark expired
    await prisma.demoSession.update({ where: { token }, data: { status: "expired" } });
    return;
  }

  await prisma.demoEvent.create({
    data: {
      sessionId: session.id,
      event,
      feature,
      metadata: metadata ? JSON.stringify(metadata) : null,
    },
  });
}

Client-side tracking hook:

typescript
// hooks/use-demo-tracking.ts
"use client";

export function useDemoTracking() {
  const track = async (event: string, feature?: string) => {
    await fetch("/api/demo/track", {
      method: "POST",
      body: JSON.stringify({ event, feature }),
    });
  };

  return { track };
}

Step 6: Guided Tour Overlay

tsx
// components/demo/guided-tour.tsx
"use client";

import { useState } from "react";

type TourStep = {
  target: string;  // CSS selector
  title: string;
  description: string;
  feature: string;
};

const TOUR_STEPS: TourStep[] = [
  {
    target: "#project-list",
    title: "Your Projects",
    description: "All your team's projects in one place. Click any to dive in.",
    feature: "project_list",
  },
  {
    target: "#invite-button",
    title: "Invite Your Team",
    description: "Add teammates with role-based access. Admins, members, viewers.",
    feature: "team_invite",
  },
  {
    target: "#analytics-nav",
    title: "Real-Time Analytics",
    description: "See exactly where time is spent across all your projects.",
    feature: "analytics",
  },
];

export function GuidedTour({ onComplete }: { onComplete: () => void }) {
  const [step, setStep] = useState(0);
  const { track } = useDemoTracking();

  const current = TOUR_STEPS[step];

  async function advance() {
    await track("tour.step_viewed", current.feature);

    if (step < TOUR_STEPS.length - 1) {
      setStep(step + 1);
    } else {
      await track("tour.completed");
      onComplete();
    }
  }

  return (
    <div className="fixed inset-0 z-50 pointer-events-none">
      {/* Backdrop with cutout for highlighted element */}
      <div className="absolute inset-0 bg-black/50" />

      {/* Tooltip */}
      <div className="absolute bottom-8 left-1/2 -translate-x-1/2 pointer-events-auto
                      bg-white rounded-2xl shadow-2xl p-6 max-w-sm w-full">
        <div className="flex justify-between items-start mb-2">
          <h3 className="font-semibold">{current.title}</h3>
          <span className="text-xs text-gray-400">{step + 1}/{TOUR_STEPS.length}</span>
        </div>
        <p className="text-sm text-gray-600 mb-4">{current.description}</p>
        <button onClick={advance} className="btn-primary w-full">
          {step < TOUR_STEPS.length - 1 ? "Next →" : "Start exploring"}
        </button>
      </div>
    </div>
  );
}

Step 7: Expiry + Convert CTA

tsx
// components/demo/demo-banner.tsx
"use client";

import { useEffect, useState } from "react";

export function DemoBanner({ expiresAt }: { expiresAt: Date }) {
  const [timeLeft, setTimeLeft] = useState("");
  const [expired, setExpired] = useState(false);

  useEffect(() => {
    const interval = setInterval(() => {
      const remaining = expiresAt.getTime() - Date.now();
      if (remaining <= 0) {
        setExpired(true);
        clearInterval(interval);
        return;
      }
      const h = Math.floor(remaining / 3600000);
      const m = Math.floor((remaining % 3600000) / 60000);
      setTimeLeft(`${h}h ${m}m`);
    }, 60000);

    return () => clearInterval(interval);
  }, [expiresAt]);

  if (expired) {
    return (
      <div className="fixed inset-0 z-50 bg-black/80 flex items-center justify-center">
        <div className="bg-white rounded-2xl p-8 max-w-md text-center space-y-4">
          <h2 className="text-2xl font-bold">Your demo has expired</h2>
          <p className="text-gray-600">
            Ready to get started for real? Sign up in 60 seconds.
          </p>
          <a href="/signup?from=demo" className="btn-primary block">
            Start free trial →
          </a>
          <p className="text-xs text-gray-400">No credit card required</p>
        </div>
      </div>
    );
  }

  return (
    <div className="bg-indigo-600 text-white text-center py-2 text-sm">
      🎭 Demo mode · Expires in {timeLeft} ·{" "}
      <a href="/signup?from=demo" className="underline font-medium">
        Create your real account →
      </a>
    </div>
  );
}

Step 8: Cleanup Expired Demos

typescript
// Cron job — run daily
export async function cleanupExpiredDemos(): Promise<void> {
  const expired = await prisma.demoSession.findMany({
    where: {
      status: "active",
      expiresAt: { lt: new Date() },
    },
    select: { id: true, tenantId: true },
  });

  for (const session of expired) {
    await prisma.$transaction([
      prisma.demoEvent.deleteMany({ where: { sessionId: session.id } }),
      prisma.demoProject.deleteMany({ where: { tenantId: session.tenantId } }),
      prisma.demoUser.deleteMany({ where: { tenantId: session.tenantId } }),
      prisma.demoTenant.delete({ where: { id: session.tenantId } }),
      prisma.demoSession.update({
        where: { id: session.id },
        data: { status: "expired" },
      }),
    ]);
  }

  console.log(`Cleaned up ${expired.length} expired demo environments`);
}

Result

  • ✅ Demo provisioned in <5 seconds via bulk seed inserts
  • ✅ Fully isolated tenant — no cross-demo data leakage
  • ✅ 24-hour auto-expiry with cleanup cron
  • ✅ Guided tour highlights key features
  • ✅ Every feature interaction tracked as a named event
  • ✅ Expiry screen with upgrade CTA to convert prospects

Payoff: Prospects explore your product on their own time, you see which features they engaged with before the sales call, and your close rate improves because demos actually happen.

Related use cases