DEV Community

Emrah G.
Emrah G.

Posted on

How We Built an Instant Live Demo System for Our SaaS Product

At RouteBot, we build transportation management software used by schools and corporations to optimize routes for thousands of daily passengers. Recently, we launched something that changed how potential customers evaluate our product: an instant live demo with zero signup.

Click a button, and within seconds you're inside a fully functional environment with sample data. No forms. No email verification. No waiting for a sales call.

In this post, I'll share the technical decisions, architecture patterns, and actual code we used to build this system. Whether you're building a SaaS product or just curious about demo infrastructure, I hope you'll find something useful here.

Try our live demo →


Table of Contents

  1. The Problem with Traditional Demos
  2. Architecture Overview
  3. Demo Account Generation
  4. Data Seeding Strategy
  5. Frontend Implementation
  6. Security Considerations
  7. Rate Limiting & Abuse Prevention
  8. Cleanup & Resource Management
  9. Lessons Learned

The Problem with Traditional Demos

Before building this system, our demo flow looked like this:

  1. User fills out a contact form
  2. Sales team reaches out within 24-48 hours
  3. Call is scheduled for the following week
  4. 30-minute screen share where we show the product
  5. User still doesn't have hands-on experience

The conversion rate was okay, but the time-to-value was terrible. Users had to invest hours before they could actually use the product.

We wanted to flip this completely:

Traditional: Form → Wait → Call → Demo → Decision
New:         Click → Use → Decision
Enter fullscreen mode Exit fullscreen mode

The goal was instant access — get users into the product within seconds.


Architecture Overview

Here's the high-level architecture of our demo system:

┌─────────────────────────────────────────────────────────────────┐
│                        Landing Page                              │
│                    (routebot.com)                                │
│                                                                  │
│    [Try Live Demo] ─────────────────────────────────────────────┤
└─────────────────────────────────────────────────────────────────┘
                                │
                                ▼
┌─────────────────────────────────────────────────────────────────┐
│                     Demo Proxy Page                              │
│                  (app.routebot.com/demo)                         │
│                                                                  │
│  1. Create demo account (POST /clients/createdemoaccount)        │
│  2. Login with credentials (POST /clients/login)                 │
│  3. Redirect to dashboard                                        │
└─────────────────────────────────────────────────────────────────┘
                                │
                                ▼
┌─────────────────────────────────────────────────────────────────┐
│                     Backend API                                  │
│                                                                  │
│  • Generate unique demo company                                  │
│  • Seed sample data (routes, passengers, vehicles)               │
│  • Create temporary credentials                                  │
│  • Apply demo restrictions                                       │
└─────────────────────────────────────────────────────────────────┘
                                │
                                ▼
┌─────────────────────────────────────────────────────────────────┐
│                     MongoDB                                      │
│                                                                  │
│  • Demo companies (auto-expire after 24h)                        │
│  • Sample data per demo instance                                 │
│  • Isolated from production data                                 │
└─────────────────────────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

Key decisions:

  • Same codebase: Demo runs on the same infrastructure as production. No separate "demo environment."
  • Real isolation: Each demo gets its own company/tenant. Data is completely isolated.
  • Automatic cleanup: Demo accounts expire and are cleaned up automatically.

Demo Account Generation

The core of the system is the demo account creation endpoint. Here's a simplified version:

// backend/controllers/clients-controller.js

const createDemoAccount = async (req, res, next) => {
  const session = await mongoose.startSession();
  session.startTransaction();

  try {
    // Generate unique identifiers
    const demoId = generateDemoId();
    const companyName = `Demo Company ${demoId}`;
    const email = `demo-${demoId}@demo.routebot.com`;
    const tempPassword = generateSecurePassword();

    // Create demo company
    const company = new Company({
      name: companyName,
      isDemo: true,
      createdAt: new Date(),
      expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24 hours
      settings: getDefaultDemoSettings(),
    });
    await company.save({ session });

    // Create demo admin user
    const hashedPassword = await bcrypt.hash(tempPassword, 12);
    const client = new Client({
      email,
      password: hashedPassword,
      name: "Demo User",
      company: company._id,
      role: "admin",
      isDemo: true,
      permissions: getDemoPermissions(),
    });
    await client.save({ session });

    // Seed sample data
    await seedDemoData(company._id, session);

    await session.commitTransaction();

    // Return credentials (temporary, for immediate login)
    res.status(201).json({
      email,
      tempPassword,
      companyId: company._id,
    });
  } catch (error) {
    await session.abortTransaction();
    return next(new HttpError("Could not create demo account", 500));
  } finally {
    session.endSession();
  }
};
Enter fullscreen mode Exit fullscreen mode

Key Implementation Details

1. Unique Demo IDs

We use a combination of timestamp and random string to ensure uniqueness:

const generateDemoId = () => {
  const timestamp = Date.now().toString(36);
  const random = Math.random().toString(36).substring(2, 8);
  return `${timestamp}-${random}`;
};
Enter fullscreen mode Exit fullscreen mode

2. Temporary Passwords

Demo passwords are randomly generated and only used for the initial login:

const generateSecurePassword = () => {
  return crypto.randomBytes(16).toString("hex");
};
Enter fullscreen mode Exit fullscreen mode

3. Demo Permissions

Demo accounts have restricted permissions — they can view and interact with most features, but certain actions are blocked:

const getDemoPermissions = () => ({
  canViewDashboard: true,
  canManageRoutes: true,
  canViewAnalytics: true,
  canSendSMS: false, // Blocked in demo
  canExportData: false, // Blocked in demo
  canDeleteCompany: false, // Blocked in demo
  canModifyBilling: false, // Blocked in demo
});
Enter fullscreen mode Exit fullscreen mode

Data Seeding Strategy

An empty demo is useless. Users need to see the product with realistic data. Here's how we approach seeding:

// backend/utils/demo-seeder.js

const seedDemoData = async (companyId, session) => {
  // Seed in order of dependencies
  const schools = await seedSchools(companyId, session);
  const vehicles = await seedVehicles(companyId, session);
  const drivers = await seedDrivers(companyId, vehicles, session);
  const passengers = await seedPassengers(companyId, schools, session);
  const routes = await seedRoutes(companyId, vehicles, passengers, session);
  const shifts = await seedShifts(companyId, routes, session);

  return {
    schools: schools.length,
    vehicles: vehicles.length,
    drivers: drivers.length,
    passengers: passengers.length,
    routes: routes.length,
  };
};
Enter fullscreen mode Exit fullscreen mode

Sample Data Templates

We maintain JSON templates for realistic sample data:

// backend/data/demo-templates/passengers.json
const passengerTemplates = [
  {
    name: "Alice Johnson",
    type: "student",
    grade: "5th",
    location: { lat: 40.7128, lng: -74.006 },
    pickupAddress: "123 Main Street",
    parentPhone: "+1555000001",
  },
  {
    name: "Bob Smith",
    type: "student",
    grade: "3rd",
    location: { lat: 40.7148, lng: -74.013 },
    pickupAddress: "456 Oak Avenue",
    parentPhone: "+1555000002",
  },
  // ... 50+ more templates
];
Enter fullscreen mode Exit fullscreen mode

Randomization for Realism

Each demo gets slightly different data to feel authentic:

const seedPassengers = async (companyId, schools, session) => {
  const templates = shuffleArray([...passengerTemplates]);
  const count = getRandomInt(30, 50); // 30-50 passengers per demo

  const passengers = templates.slice(0, count).map((template, index) => ({
    ...template,
    company: companyId,
    school: schools[index % schools.length]._id,
    // Add slight location variance
    location: {
      lat: template.location.lat + (Math.random() - 0.5) * 0.01,
      lng: template.location.lng + (Math.random() - 0.5) * 0.01,
    },
  }));

  return await Passenger.insertMany(passengers, { session });
};
Enter fullscreen mode Exit fullscreen mode

Frontend Implementation

The frontend demo page is a React component that orchestrates the entire flow:

// frontend/src/pages/auth/Demo.js

import React, { useContext, useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { useHttpClient } from "shared/hooks/http-hook";
import { AuthContext } from "shared/context/auth-context";
import { getDeviceId } from "shared/util/auth/device-id";
import LoadingSpinner from "shared/components/UIElements/LoadingSpinner";

const Demo = () => {
  const auth = useContext(AuthContext);
  const { t } = useTranslation();
  const { sendRequest } = useHttpClient();
  const navigate = useNavigate();
  const [error, setError] = useState(null);

  useEffect(() => {
    const loginWithDemo = async () => {
      // Already logged in? Redirect to dashboard
      if (auth.isLoggedIn) {
        navigate("/dashboard");
        return;
      }

      try {
        // Step 1: Create demo account
        const created = await sendRequest(
          `${process.env.REACT_APP_BACKEND_URL}/clients/createdemoaccount`,
          "POST",
          JSON.stringify({}),
        );

        // Step 2: Login with demo credentials
        const loginResponse = await sendRequest(
          `${process.env.REACT_APP_BACKEND_URL}/clients/login`,
          "POST",
          JSON.stringify({
            phonenumber: created.email,
            password: created.tempPassword,
            language: "en",
            deviceId: getDeviceId(),
          }),
          { "Content-Type": "application/json" },
        );

        // Step 3: Complete authentication
        await auth.login(loginResponse.id, loginResponse.token);
        // Redirect happens automatically via auth state change
      } catch (err) {
        setError(err?.message || t("anErrorOccurred"));
      }
    };

    loginWithDemo();
  }, [auth, navigate, sendRequest, t]);

  if (error) {
    return (
      <div className="demo-error-container">
        <h1>{t("auth.demoError")}</h1>
        <p>{error}</p>
        <button onClick={() => window.location.reload()}>{t("auth.tryAgain")}</button>
        <button onClick={() => navigate("/login")}>{t("auth.goToLogin")}</button>
      </div>
    );
  }

  return (
    <div className="demo-loading-container">
      <LoadingSpinner />
      <h1>{t("auth.preparingDemo")}</h1>
      <p>{t("auth.pleaseWait")}</p>
    </div>
  );
};

export default Demo;
Enter fullscreen mode Exit fullscreen mode

Device ID for Analytics

We track demo sessions using a device ID (stored in localStorage):

// frontend/src/shared/util/auth/device-id.js

export const getDeviceId = () => {
  let deviceId = localStorage.getItem("deviceId");

  if (!deviceId) {
    deviceId = generateUUID();
    localStorage.setItem("deviceId", deviceId);
  }

  return deviceId;
};

const generateUUID = () => {
  return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
    const r = (Math.random() * 16) | 0;
    const v = c === "x" ? r : (r & 0x3) | 0x8;
    return v.toString(16);
  });
};
Enter fullscreen mode Exit fullscreen mode

Security Considerations

Running user-facing demo infrastructure requires careful security planning.

1. Demo Account Isolation

Each demo account is completely isolated at the database level:

// All queries include company filter
const getRoutes = async (companyId) => {
  return await Route.find({ company: companyId });
};
Enter fullscreen mode Exit fullscreen mode

2. Action Restrictions

We use middleware to block sensitive actions:

// backend/middleware/demo-guard.js

const demoGuard = (blockedActions) => {
  return async (req, res, next) => {
    const client = req.userData;

    if (client.isDemo && blockedActions.includes(req.action)) {
      return res.status(403).json({
        message: "This action is not available in demo mode",
        upgradeUrl: "/sign-up",
      });
    }

    next();
  };
};

// Usage
router.post("/send-sms", demoGuard(["SEND_SMS"]), smsController.sendSMS);
Enter fullscreen mode Exit fullscreen mode

3. Rate Limiting Demo Creation

We prevent abuse with rate limiting:

// backend/middleware/rate-limit.js

const demoRateLimiter = rateLimit({
  windowMs: 60 * 60 * 1000, // 1 hour
  max: 5, // 5 demo accounts per IP per hour
  message: {
    error: "Too many demo accounts created. Please try again later.",
  },
  keyGenerator: (req) => {
    return req.ip || req.headers["x-forwarded-for"];
  },
});

router.post("/createdemoaccount", demoRateLimiter, createDemoAccount);
Enter fullscreen mode Exit fullscreen mode

Rate Limiting & Abuse Prevention

Beyond basic rate limiting, we implement several layers of protection:

IP-Based Throttling

const rateLimitConfig = {
  demo: {
    windowMs: 60 * 60 * 1000, // 1 hour
    maxRequests: 5,
    blockDuration: 24 * 60 * 60 * 1000, // 24 hour block after abuse
  },
};

const checkRateLimit = async (ip, action) => {
  const key = `ratelimit:${action}:${ip}`;
  const current = await redis.incr(key);

  if (current === 1) {
    await redis.expire(key, rateLimitConfig[action].windowMs / 1000);
  }

  if (current > rateLimitConfig[action].maxRequests) {
    // Block the IP
    await redis.setex(`blocked:${ip}`, rateLimitConfig[action].blockDuration / 1000, "1");
    throw new Error("Rate limit exceeded");
  }

  return true;
};
Enter fullscreen mode Exit fullscreen mode

Honeypot Fields

We add invisible form fields to catch bots:

<form onSubmit={handleDemoStart}>
  {/* Honeypot - hidden from users, filled by bots */}
  <input type="text" name="website" style={{ display: "none" }} tabIndex="-1" autoComplete="off" />
  <button type="submit">Try Live Demo</button>
</form>
Enter fullscreen mode Exit fullscreen mode

Cleanup & Resource Management

Demo accounts generate data that needs to be cleaned up. We use a scheduled job:

// backend/crons/cleanup-demo-accounts.js

const cleanupDemoAccounts = async () => {
  const expiredDemos = await Company.find({
    isDemo: true,
    expiresAt: { $lt: new Date() },
  });

  for (const company of expiredDemos) {
    const session = await mongoose.startSession();
    session.startTransaction();

    try {
      // Delete all related data
      await Promise.all([
        Client.deleteMany({ company: company._id }, { session }),
        Route.deleteMany({ company: company._id }, { session }),
        Passenger.deleteMany({ company: company._id }, { session }),
        Vehicle.deleteMany({ company: company._id }, { session }),
        Driver.deleteMany({ company: company._id }, { session }),
        // ... other models
      ]);

      // Delete the company
      await Company.deleteOne({ _id: company._id }, { session });

      await session.commitTransaction();
      console.log(`Cleaned up demo company: ${company._id}`);
    } catch (error) {
      await session.abortTransaction();
      console.error(`Failed to cleanup: ${company._id}`, error);
    } finally {
      session.endSession();
    }
  }
};

// Run every hour
cron.schedule("0 * * * *", cleanupDemoAccounts);
Enter fullscreen mode Exit fullscreen mode

Monitoring Demo Usage

We track demo metrics for insights:

const trackDemoMetrics = async (action, metadata) => {
  await DemoAnalytics.create({
    action,
    timestamp: new Date(),
    ip: metadata.ip,
    userAgent: metadata.userAgent,
    deviceId: metadata.deviceId,
    duration: metadata.duration,
  });
};

// Track demo creation
await trackDemoMetrics("DEMO_CREATED", {
  ip: req.ip,
  userAgent: req.headers["user-agent"],
});

// Track demo exploration
await trackDemoMetrics("FEATURE_EXPLORED", {
  feature: "route-optimization",
  deviceId: req.body.deviceId,
});
Enter fullscreen mode Exit fullscreen mode

Lessons Learned

After running this system in production for several months, here's what we've learned:

1. Instant Access Dramatically Increases Engagement

Our demo-to-signup conversion improved by 3x after removing the signup barrier. Users who experience the product firsthand are much more likely to create a real account.

2. Sample Data Quality Matters

Early versions had obviously fake data ("Test User 1", "123 Fake Street"). Users didn't take the demo seriously. Investing in realistic sample data made a huge difference.

3. Demo Restrictions Need UX Consideration

When users hit a restriction (like trying to send SMS), don't just show an error. Explain why it's blocked and how to unlock it:

<Modal>
  <h3>SMS is disabled in demo mode</h3>
  <p>Create a free account to send real notifications to passengers.</p>
  <Button href="/sign-up">Create Account</Button>
</Modal>
Enter fullscreen mode Exit fullscreen mode

4. Monitor for Abuse Early

We underestimated how quickly bad actors would find the endpoint. Implement rate limiting and monitoring from day one.

5. Keep Demo Environment in Sync with Production

Running demos on the same codebase as production means every new feature is automatically available in demos. This was a great decision.


Conclusion

Building an instant demo system was one of the best investments we made for RouteBot. It reduced friction, increased engagement, and gave potential customers the confidence to make decisions faster.

If you're building a SaaS product and still relying on traditional demo calls, consider giving users direct access. The technical challenges are solvable, and the impact on conversions is significant.

Want to see how it works? Try our live demo →


Resources


If you found this useful, follow me for more technical deep-dives on SaaS development. Questions? Drop a comment below!

Top comments (0)