SaaS Demo Data: Build Demo Accounts That Tell a Story
SaaS demo data has a different job from every other kind of fake data. It isn't there to cover code paths. It's there to tell a story: this product is in use, the numbers are going the right way, and every record you click leads somewhere sensible. The same dataset has to carry a live sales demo, the screenshots on your marketing site and in your docs, and the pre-populated workspace a new trial user sees first. A demo data generator that sprays random rows across your tables gets the volume right and the story wrong.
This post shows how to build demo data that tells that story: hero accounts the presenter can find by name, a dashboard with a believable upward trend, customers, orders, and invoices that agree with each other, nothing embarrassing on screen, and the same numbers every time until you regenerate on purpose. The templates run in JsonFabrica, and it's worth being clear about the boundary up front. JsonFabrica returns JSON over HTTP and nothing else. It has no database connection, doesn't create accounts or tenants, and doesn't draw charts. Your own import step loads the JSON into your app's database, and your own dashboard renders the charts from it.
If you're looking for how often to refresh a shared QA environment and how to bookmark fixed seeds for bug reports, that's covered in staging environment test data. This post is about the demo narrative and how it looks to a prospect.
SaaS demo data is a story, not test coverage
Good test data is hostile. It's full of empty strings, nulls, negative amounts, and values one past the limit, because its job is to break things. Boundary value test data generation covers how to build exactly that. Demo data is the mirror image. It deliberately leaves all of those out, because a prospect who sees a negative invoice or a customer called "test123" stops listening to the pitch.
In practice, demo data that works has five properties:
- Hero records. Two or three named accounts the presenter can search for and open in every demo, with a history rich enough to show off the product.
- A plausible trend. The main chart climbs, with some wobble, instead of drawing flat random noise.
- Consistent relationships. A customer's orders, and the invoices for those orders, tell one story: same customer, same amounts, dates in the right order.
- Nothing embarrassing. No lorem ipsum, no "Test User", no negative prices, no joke names, no email addresses that look machine-made.
- Stability. Every screenshot, docs page, and live demo shows the same numbers. The data changes when you decide to regenerate it, not on every request.
The rest of this post builds each one. The example product is a B2B app that tracks customers, orders, and invoices, and has a usage dashboard. Swap in your own entities; the techniques carry over.
Hero accounts the presenter can find by name
A presenter who has to say "let me find a good one" has already lost a little momentum. Give them hero accounts with fixed names, fixed ids, and a known history, so the demo script can say "open Ridgeline Freight" and it's always there.
The rule for heroes: hand-write everything the presenter says out loud, and generate everything nobody reads. The hero template is mostly literal, with generated values only for details like the street address:
{
"id": "cus_ridgeline",
"company": "Ridgeline Freight",
"segment": "Enterprise",
"contactName": "Dana Whitfield",
"email": "[email protected]",
"sinceMonthsAgo": 26,
"city": "<getRandomCity()>",
"street": "<getRandomStreet()>"
}
The literal id is what makes the hero findable: the demo script, a
deep link in your docs, and a screenshot annotation can all point at
cus_ridgeline and never break. Each hero gets its own small template
and its own alias in the batch request below. More than three or four
heroes is usually a sign that the demo script is trying to do too much.
The sinceMonthsAgo field is not a date, on purpose. The section on
keeping dates fresh explains why.
Customers, orders, and invoices that tell the same story
Around the heroes you need a believable population: a few dozen customers, a few hundred orders, and an invoice for each order. The customer template builds the contact name first and reuses it for the email, so the two always match:
<setVar('fn', getRandomName())>
<setVar('ln', getRandomSurname())>
<setVar('kind', getRandomElement('Logistics', 'Dental', 'Analytics', 'Foods'))>
<setVar('seg', getRandomElement('SMB', 'SMB', 'SMB', 'Mid-size', 'Enterprise'))>
{
"id": "cus_<getRandomNumber(10000000, 99999999)>",
"company": "<getRandomSurname()> <getVar('kind')>",
"segment": "<getVar('seg')>",
"sinceMonthsAgo": <getRandomNumber(6, 30)>,
"city": "<getRandomCity()>",
"contactName": "<getVar('fn')> <getVar('ln')>",
"email": "<toLowerCase(getVar('fn'))>.<toLowerCase(getVar('ln'))>@example.com"
}
getRandomElement picks one of its
arguments uniformly at random, so repeating a value weights it: 'SMB'
appears three times out of five, which makes 60% of customers small
businesses. The company name pairs a surname with an industry word, which
gives you "Garcia Dental" and "Novak Analytics" rather than anything that
reads as generated.
The order and invoice templates leave their link fields as null. The
batch request fills them in:
<setVar('m', getRandomElement(0,0,0,0,0,0,1,1,1,1,1,2,2,2,2,3,3,3,4,4,5))>
{
"id": "ord_<getRandomNumber(10000000, 99999999)>",
"customerId": null,
"monthsAgo": <getVar('m')>,
"dayOffset": <getRandomNumber(0, 29)>,
"total": <getRandomNumber(180, 2400, 2)>,
"currency": "USD"
}
{
"id": "inv_<getRandomNumber(10000000, 99999999)>",
"orderId": null,
"amount": null,
"currency": "USD",
"status": "<getRandomElement('paid', 'paid', 'paid', 'paid', 'open')>"
}
Save the templates with
POST /v1/templates, then generate the
whole dataset in one batch request:
{
"seed": 20260929,
"documents": [
{ "templateId": "tpl_demo_hero", "alias": "hero", "count": 1 },
{ "templateId": "tpl_demo_customer", "alias": "customers", "count": 40 },
{
"templateId": "tpl_demo_order",
"alias": "hero_orders",
"count": 30,
"relations": { "customerId": "hero.id" }
},
{
"templateId": "tpl_demo_order",
"alias": "orders",
"count": 160,
"relations": {
"customerId": { "from": "customers.id", "strategy": "round-robin" }
}
},
{
"templateId": "tpl_demo_invoice",
"alias": "hero_invoices",
"count": 30,
"relations": {
"orderId": { "from": "hero_orders.id", "strategy": "round-robin" },
"amount": { "from": "hero_orders.total", "strategy": "round-robin" }
}
},
{
"templateId": "tpl_demo_invoice",
"alias": "invoices",
"count": 160,
"relations": {
"orderId": { "from": "orders.id", "strategy": "round-robin" },
"amount": { "from": "orders.total", "strategy": "round-robin" }
}
}
]
}
A relation copies a field from another alias's generated documents into
this one, after generation. round-robin is the only strategy: the
child at position i takes its value from the parent at position i
modulo the parent count. That has three consequences worth knowing:
- Every order points at a real customer. The 160 orders cycle through the 40 customers, so each one gets exactly four. The spread is even, which is why the hero gets its own 30 orders: it stands out as the busiest account in the demo.
- Invoices match their orders exactly. Both
orderIdandamountcome from the same order, because both relations use the same position. Keep the invoice count equal to the order count, and every order gets exactly one invoice with the same amount. - Relations can copy any field, not only ids. Copying
totalintoamountis what keeps the invoice list and the revenue chart from disagreeing in front of a prospect.
The hero relation uses the short string form, "hero.id", which is only
allowed when the parent alias has a count of 1. The response groups the
generated documents by alias, under results.hero, results.customers,
results.orders, and so on. The OpenAPI reference currently shows
results as a flat array; the running API returns the grouped object
shown here.
A dashboard trend that climbs instead of flatlining
Uniform random dates give you a flat revenue chart, and a flat chart tells the prospect the product isn't working. The order template gets its trend from the same repetition trick as the customer segment. The month list in its first line contains six 0s, five 1s, and so on down to one 5, so the most recent month gets the most orders. That's a 1/2/3/4/5/6 split out of 21, so for 160 orders the expected counts are about 8, 15, 23, 30, 38, and 46 per month from oldest to newest. Any given seed wobbles a few orders either side of these, which reads as a real business growing rather than a line drawn with a ruler.
These "months" are 30-day buckets counted back from the reset date, not calendar months. Group the chart by rolling 30-day periods, or leave the partial current calendar month off it. Otherwise the last bar only covers the days elapsed so far, and the chart dips right at the end.
The template language has comparison and logic operators but no
arithmetic, so you can't compute a value from a loop index, as in "index
times 100". For a chart built from a series, such as weekly active users,
use if and elseIf on the loop index to pick a
higher random range for later periods. This usage template for the hero
account emits twelve weeks of data, oldest first, plus the hero's team
with their last-active times:
<setVar('from', getParam('activeFrom'))>
<setVar('to', getParam('activeTo'))>
{
"accountId": "cus_ridgeline",
"weeklyActiveUsers": [<for(w, 11, 0, -1)>
<if(getVar('w') >= 8)><setVar('lo', 40)><setVar('hi', 55)>
<elseIf(getVar('w') >= 4)><setVar('lo', 55)><setVar('hi', 75)>
<else><setVar('lo', 75)><setVar('hi', 95)><endIf>
{
"weeksAgo": <getVar('w')>,
"users": <getRandomNumber(getVar('lo'), getVar('hi'))>
}<if(getVar('w') > 0)>,<endIf><end_for>
],
"members": [<for(k, 1, 6)>{
"name": "<getRandomFullName()>",
"lastActiveAt": "<getRandomDate(getVar('from'), getVar('to'))>"
}<if(getVar('k') < 6)>,<endIf><end_for>]
}
A few details in this template are easy to get wrong:
- Loop bounds are inclusive.
for(w, 11, 0, -1)runs twelve times, counting down from 11 to 0, so the array comes out oldest week first.for(k, 1, 6)produces six members. - Read the loop variable with
getVar('w'). A barewevaluates to the string"w", not the loop's current value. - Bands give the trend; randomness gives the wobble. Weeks 11 to 8 draw from 40 to 55 users, weeks 7 to 4 from 55 to 75, and the last four weeks from 75 to 95. Within a band, some weeks dip, which is what real usage looks like.
- The comma guard.
<if(getVar('w') > 0)>,<endIf>puts a comma between items but not after the last one, so the output is valid JSON.
Each generation runs under four limits: 10,000 loop iterations, 100,000
node evaluations, 2,000 ms of evaluation time, and 8 MiB of output. For
banded series like this one, the node-evaluation budget fills up first.
Each iteration of the loop above costs roughly 30 to 40 evaluations
between the branch, the setVar calls, the placeholders, and the comma
guard. A year of daily points for a chart or two fits comfortably, but
five such charts in one template would use about two-thirds of the
budget. Past that, split the series across separate templates.
Sample data for product demos: nothing that embarrasses you on screen
The fastest way to lose a room is a screenshot with "Lorem ipsum dolor" in the notes column. A few rules keep sample data for product demos presentable:
- Don't use
getRandomTextWithSpacesfor anything visible. It builds text from lorem ipsum words. For notes, product names, or ticket subjects, write a short list of realistic values and pick from it withgetRandomElement. - Build emails from the contact's name. The built-in
getRandomEmailgenerates a different random name from the contact's, adds a numeric suffix, and picks from test domains such asmail.test. That's fine for tests and obvious in a demo. Composing[email protected]from the same variables ascontactNamekeeps them matched, andexample.comis reserved, so a demo that accidentally sends an email doesn't reach anyone. - Set floors on every number.
getRandomNumber(180, 2400, 2)can never produce a zero or negative order total. For prices that should look like a price list, pick from literal values instead, such asgetRandomElement(49, 99, 149). - No numbered placeholders. "Customer 17" and "Acme Test 3" read as fake instantly. Sequences are good at generating unique labels, which is exactly the problem here.
- Read the output once. With a fixed seed, the dataset is the same on every run, so one careful read-through covers every future demo. If a generated name happens to read badly next to your product, change the seed or the word list and read it again.
Realistic demo data for screenshots: same seed, same numbers
Marketing screenshots, docs pages, and the live demo should all show the
same revenue figure. If one of them says $48,210 and another says
$51,907, someone will notice. Stability comes from the seed in the
batch request. Each document's random values are derived from the batch
seed, its alias, and its position in that alias, so the same request with
the same seed returns the same customers, orders, amounts, and
statuses. The batch response echoes the seed back in its top-level
seed field, so it's recorded next to the data.
Regenerate the dataset only on purpose, then commit the result:
#!/usr/bin/env bash
# regenerate-demo.sh: run on purpose, review the diff, commit the result
set -euo pipefail
API=https://api.jsonfabrica.com/v1
AUTH="Authorization: Bearer $JSONFABRICA_API_KEY"
RES=$(curl -fsS -X POST "$API/batches" -H "$AUTH" \
-H 'content-type: application/json' -d @demo/batch.json)
BATCH_ID=$(jq -r .batchId <<<"$RES")
while jq -e '.status == "queued" or .status == "running"' \
<<<"$RES" > /dev/null; do
sleep 2
RES=$(curl -fsS "$API/batches/$BATCH_ID" -H "$AUTH")
done
jq -e '.status == "completed"' <<<"$RES" > /dev/null \
|| { echo "$RES" >&2; exit 1; }
# Random ids are stable per seed, so check them for duplicates once, here.
DUPES=$(jq '[.results[][].id] | length - (unique | length)' <<<"$RES")
[ "$DUPES" -eq 0 ] || { echo "duplicate ids, try another seed" >&2; exit 1; }
jq . <<<"$RES" > demo/batch-result.json
echo "seed=$(jq .seed demo/batch-result.json) batch=$BATCH_ID"
This batch has 421 documents in total. Batches with 50 or more documents
come back as 202 Accepted with a batchId, which the script polls
until status is completed. The duplicate check exists because the ids
come from getRandomNumber over a wide range. A collision is unlikely,
and with a fixed seed you only need to check once.
A few things break stability, and it's better to know them in advance:
- Sequences. Sequence values are durable counters that keep advancing
between runs, so a fixed seed doesn't pin them. That's why the demo
templates use literal or seeded random ids instead of
createSeqandgetSeq. - Template edits. Random values are drawn in document order. Adding a random field at the end of a template leaves the earlier values alone; adding one near the top changes everything after it.
- Parent counts. Raising
customersfrom 40 to 50 keeps the first 40 customers identical, but round-robin then assigns orders to different customers, so per-customer totals change.
Commit demo/batch-result.json and review its diff like any other
change. Screenshots only need retaking when that file changes.
Keeping demo dates fresh with params
Stable data has one natural enemy: time. A dataset generated in January with hardcoded dates shows "last active 8 months ago" by September, and the revenue chart ends in a month that has already passed. A trial user notices that immediately.
JsonFabrica has no "now" function and no date arithmetic.
getRandomDate takes an explicit
ISO-8601 range, and formatDate only
reformats a date with the YYYY, MM, DD, HH, mm, and ss
tokens. So the fix is to pass the current window in at regeneration time.
The usage template above reads its range with
getParam, and your reset script supplies
it:
{
"seed": 20260929,
"params": {
"activeFrom": "2026-09-26T00:00:00Z",
"activeTo": "2026-09-29T09:00:00Z"
}
}
That's the body of POST /v1/templates/{templateId}/generate, and the
response comes back as data plus a meta object that includes the
seed. Because getParam('activeFrom') has no default, generation fails
with an error if the script forgets the window, instead of silently
producing old dates. And because the seed is fixed, sliding the window
forward keeps the same members in the same order of recency; only the
timestamps move.
Batch requests don't pass params through to templates, so the batch
templates use the other approach: they store dates as offsets
(monthsAgo, dayOffset, sinceMonthsAgo, weeksAgo) and your import
step turns them into dates relative to today. The committed batch file
never goes stale, because it doesn't contain a single absolute date.
Pre-populated trial account data and demo resets
The last step is yours, and it's deliberately small: turning the batch into pre-populated trial account data or a fresh demo tenant. JsonFabrica has handed you JSON. Your reset script resolves the offsets, fetches the fresh usage document, and passes everything to your app's own loader, which writes it into the demo or trial tenant:
// demo-reset.mjs: rebuild one demo or trial tenant from the dataset
import { readFile } from "node:fs/promises";
import { loadDemoTenant } from "./app/demo-loader.js"; // your code
const API = "https://api.jsonfabrica.com/v1";
const asOf = new Date(process.env.DEMO_AS_OF ?? Date.now());
const DAY = 24 * 60 * 60 * 1000;
const daysAgo = (n) => new Date(asOf.getTime() - n * DAY).toISOString();
const file = await readFile("demo/batch-result.json", "utf8");
const r = JSON.parse(file).results;
const res = await fetch(`${API}/templates/tpl_demo_hero_usage/generate`, {
method: "POST",
headers: {
authorization: `Bearer ${process.env.JSONFABRICA_API_KEY}`,
"content-type": "application/json",
},
body: JSON.stringify({
seed: 20260929,
params: { activeFrom: daysAgo(3), activeTo: asOf.toISOString() },
}),
});
if (!res.ok) throw new Error(await res.text());
const usage = (await res.json()).data;
const orders = [...r.hero_orders, ...r.orders].map((o) => ({
...o,
placedAt: daysAgo(o.monthsAgo * 30 + o.dayOffset),
}));
const placedAt = new Map(orders.map((o) => [o.id, o.placedAt]));
await loadDemoTenant(process.env.TENANT_ID, {
customers: [...r.hero, ...r.customers].map((c) => ({
...c,
createdAt: daysAgo(c.sinceMonthsAgo * 30),
})),
orders,
invoices: [...r.hero_invoices, ...r.invoices].map((i) => ({
...i,
issuedAt: placedAt.get(i.orderId),
})),
weeklyActiveUsers: usage.weeklyActiveUsers.map((w) => ({
...w,
weekOf: daysAgo(w.weeksAgo * 7),
})),
members: usage.members,
});
Each invoice takes its date from its own order, so the story holds together even after the dates move: no invoice is older than the order it bills, and customer tenure starts at six months, so no customer has an order older than the account itself.
The same script covers all three uses:
- Live sales demos. Run it before each demo, or on a schedule, to restore the sales demo environment data after a presenter has clicked around in it.
- Screenshots and docs. Set
DEMO_AS_OFto a fixed date. The offsets then resolve to the same calendar dates on every run, and the usage request gets the same window, so a retaken screenshot matches the old one exactly. - Pre-populated trial accounts. Call your loader from the signup
flow with the new tenant's id. If trial tenants share database tables,
your loader has to scope or remap ids per tenant:
cus_ridgelineis one id in the JSON, and JsonFabrica knows nothing about your tenants.
Only the usage request calls the API at reset time. The batch was generated once and committed. Generation counts toward your plan's usage, so this also keeps a busy signup flow cheap.
FAQ
What is demo data in a SaaS product? Demo data is the fictional content that fills a SaaS product for sales demos, marketing screenshots, documentation, and new trial accounts. Unlike test data, its job is to make the product look alive and believable: named accounts the presenter can find, dashboards with a plausible trend, related records that agree with each other, and no placeholder text. It should be stable, so every demo and screenshot shows the same numbers.
How do I create realistic demo data for a product demo? Decide the story first: which two or three hero accounts the presenter will open, what the main dashboard should show, and which features need data to look used. Hand-write the values the presenter says out loud, such as hero company names, and generate the rest from templates that only produce plausible values. Link related records so orders, invoices, and customers agree, then generate everything from a fixed seed and review it once.
How do I keep demo data dates from going stale? Don't hardcode absolute dates in the dataset. Either pass the current date window into the generator at regeneration time, for example as request params that a template reads for its date range, or store dates as offsets such as "3 months ago" and resolve them against today's date when you load the data. Both keep last-active times and chart axes current without changing the rest of the story.
What is the difference between demo data and test data? Test data is built to find bugs, so good test data deliberately includes nulls, negative numbers, boundary values, and malformed input. Demo data is built to sell and explain the product, so it deliberately excludes all of that and favors believable, consistent, stable values. Most teams need both, generated separately, and should never reuse edge-case fixtures in a demo account.
How do I get the same demo data every time I regenerate it? Use a generator that accepts a seed and send the same seed on every request. With JsonFabrica, a batch request with a fixed seed returns the same random values each time as long as the templates and counts are unchanged, and the seed is echoed back in the response so you can record it. Avoid sequence-backed ids in demo data, because sequences keep counting between runs even when the seed is fixed.
Can JsonFabrica create demo accounts or trial workspaces in my app? No. JsonFabrica only returns generated JSON over HTTP. It has no connection to your database, does not create users, tenants, or trial accounts, and does not render charts. Your own seed or import script loads the JSON into your app's database for the demo or trial tenant, and your own dashboard draws the charts from that data.
A demo that tells a story comes down to three JsonFabrica features: a fixed seed, batch relations, and templates that only produce values you'd be happy to show a prospect. All three are available through the API, and the batches reference covers the request format used throughout this post.
Generate realistic test data with JsonFabrica
Describe the shape of your data once, then generate as many fresh, realistic JSON documents as you need via a simple API call.