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.
Table of Contents
- The Problem with Traditional Demos
- Architecture Overview
- Demo Account Generation
- Data Seeding Strategy
- Frontend Implementation
- Security Considerations
- Rate Limiting & Abuse Prevention
- Cleanup & Resource Management
- Lessons Learned
The Problem with Traditional Demos
Before building this system, our demo flow looked like this:
- User fills out a contact form
- Sales team reaches out within 24-48 hours
- Call is scheduled for the following week
- 30-minute screen share where we show the product
- 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
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 │
└─────────────────────────────────────────────────────────────────┘
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();
}
};
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}`;
};
2. Temporary Passwords
Demo passwords are randomly generated and only used for the initial login:
const generateSecurePassword = () => {
return crypto.randomBytes(16).toString("hex");
};
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
});
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,
};
};
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
];
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 });
};
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;
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);
});
};
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 });
};
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);
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);
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;
};
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>
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);
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,
});
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>
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)