json-server Fake Data: Generate db.json From One Seeded Template
json-server fake data usually starts as a db.json typed by hand: two
users, three posts, a comment or two, and then a slow drift as people
add records for whatever screen they're building. json-server does
exactly one job with that file. It turns each top-level array into a
REST resource with routes, filters, pagination and relationships. It
does not generate, randomize or check the data. If a comment points at
a post that doesn't exist, json-server serves it anyway.
This post moves the work to where the file is produced. One seeded
JsonFabrica template generates the whole db.json: users, posts and
comments with ids and foreign keys that line up, so _embed works,
plus a handful of deliberately awkward records for your UI and tests
to deal with. A short script you own regenerates the file from a seed,
you commit the result, and json-server serves it as before.
To be clear about the boundaries: JsonFabrica is a JSON-over-HTTP API. There is no json-server plugin and no JsonFabrica package. Your script calls the API, and json-server reads a file.
Why a hand-written db.json stops scaling
A hand-written db.json is fine at five records. If they rarely
change, they don't need a template, a script or an API key; writing
them by hand is quicker and anyone can read the file. It gets harder
as the file grows:
- Foreign keys drift. Every
postIdanduserIdis typed by hand. Delete a post while cleaning up and its comments now point at nothing. json-server won't complain; the comment's post simply never shows up in_embed. - The edge cases never get written. Nobody types a 3,000-character post body, a user with no posts, or a draft with no publish date until a bug report asks for one. The mock API covers the happy path and nothing else.
- There's no way to get more of it. Need 200 comments to test pagination? That's an afternoon of copy and paste, or a one-off script nobody keeps.
- Nobody knows how it was made. When the file is edited by many hands, you can't rebuild it or say which records are there on purpose.
If you only need responses for a few endpoints, intercepted in the browser, see mock API response data for frontend development. This post is for the case where you want a whole fake REST API with related collections, which is what json-server is good at.
How json-server relationships work
json-server links collections purely by naming convention:
- Children of a record. A comment with
"postId": "p6"belongs to postp6.GET /posts/p6?_embed=commentsadds acommentsarray to the post. - The parent of a record. In json-server v1, the singular form
includes the parent:
GET /posts/p6?_embed=useradds auserobject looked up through the post'suserId. In 0.17 this was a separate parameter,_expand=user. The v1 README lists the switch to_embedin its v0-to-v1 migration notes. - Filters on the foreign key.
GET /comments?postId=p6returns the same comments as a flat list.
The convention is strict, so the generated file has to follow it:
collection names are plural (users, posts, comments), and
foreign keys are the singular name plus Id.
Why the ids are strings like "p1"
The id type matters more in v1 than it did in 0.17. I tested this
against json-server 1.0.0-beta.15, the version npm installed when
this post was written:
- v1 converts numeric
idvalues to strings when it loads the file, and gives any record without anida random one. Foreign keys are left as they are. With"id": 1on a post and"postId": 1on a comment,GET /posts/1?_embed=commentsreturns"comments": [], because the string"1"doesn't equal the number1. - Filters go the other way. v1 turns a numeric-looking query value
into a number, so with
"postId": "1"stored as a string,GET /comments?postId=1returns[].
Ids with a letter prefix ("u1", "p1", "c1") avoid both problems:
they're strings in the file, and they stay strings in the query. That
is why the template below builds them that way. Since v1 is still in
beta, check this against the version you pin.
A db.json generator template for users, posts and comments
The template below produces all three collections in one document. Users and posts are counted by parameters, and those counts are stored in variables because they're read twice: once as a loop bound and again as the upper limit for picking a foreign key.
<setVar('users', getParam('users', 6))>
<setVar('posts', getParam('posts', 12))>{
"users": [
<for(i, 1, getVar('users'))><if(getVar('i') > 1)>,<endIf>
{
"id": "u<getVar('i')>",
"name": "<getRandomFullName()>",
"email": "<getRandomEmail('example.com')>",
"city": "<getRandomCity()>"<if(getVar('i') != 2)>,
"bio": <if(getVar('i') == 1)>""
<else>"<getRandomTextWithSpaces(40, 160)>"<endIf><endIf>
}<end_for>
],
"posts": [
<for(i, 1, getVar('posts'))><if(getVar('i') > 1)>,<endIf>
{
"id": "p<getVar('i')>",
"userId": <if(getVar('i') == 4)>"u0"
<else>"u<getRandomNumber(2, getVar('users'))>"<endIf>,
"title": <if(getVar('i') == 2)>"<getRandomTextWithSpaces(150, 200)>"
<elseIf(getVar('i') == 3)>""
<else>"<getRandomTextWithSpaces(20, 60)>"<endIf>,
"body": <if(getVar('i') == 2)>"<getRandomTextWithSpaces(2000, 4000)>"
<else>"<getRandomTextWithSpaces(120, 400)>"<endIf>,
"publishedAt": <if(getVar('i') == 5)>null
<else>"<getRandomDate('2026-01-01', '2026-10-01')>"<endIf>
}<end_for>
],
"comments": [
<for(i, 1, getParam('comments', 40))><if(getVar('i') > 1)>,<endIf>
{
"id": "c<getVar('i')>",
"postId": <if(getVar('i') <= 10)>"p6"
<else>"p<getRandomNumber(2, getVar('posts'))>"<endIf>,
"userId": "u<getRandomNumber(1, getVar('users'))>",
"body": <if(getVar('i') == 1)>""
<else>"<getRandomTextWithSpaces(10, 200)>"<endIf>
}<end_for>
]
}
How it fits together:
- Ids come from the loop index.
"u<getVar('i')>"givesu1tou6. They're the same on every run with the same parameters, so your tests can refer top4by name. - Foreign keys are drawn from the valid range. A post's
userIdisuplusgetRandomNumberbetween 2 and the user count; a comment'spostIdis between 2 and the post count. Every key points at a real record, except the one planted not to. - Only reused values are variables.
setVarstores the two counts that are read twice. The comment count is only a loop bound, sogetParamis called inline there. - The comma guard comes first.
<if(getVar('i') > 1)>,<endIf>writes a comma before every item except the first, so the arrays are valid JSON at any count.forbounds are inclusive.
Template expressions support comparisons (==, !=, <, <=, >,
>=) and &&/||, but no arithmetic. That's why the records that
must never be picked as a parent (u1 as a post author, p1 as a
comment's post) sit at index 1, where a fixed lower bound of 2 skips
them. Putting them at the end would need "count minus
one" as the upper bound. It's also why each planted record is
selected by its loop index. The
control flow docs cover for and
if/elseIf/else.
Planted edge records
Each awkward record sits at a fixed index, so it's in the file on every regeneration, not only when a random draw happens to produce it:
u1, a new user. Theirbiois an empty string, and they have no posts at all, because post authors are drawn fromu2upward.GET /users/u1?_embed=postsreturns"posts": [], which is your empty profile page.u2, missing optional fields. Thebiokey isn't there at all. The comma and the field sit inside oneif, so the JSON stays valid. Empty and missing are different bugs:bio.lengthworks on one and throws on the other.p1, a post with no comments. CommentpostIds are drawn fromp2upward, so?_embed=commentsonp1is always empty.p2, very long text. A 150-200 character title and a 2,000-4,000 character body, for truncation, line clamping and card layouts.p3, an empty title. What does the list show, and is the link still clickable?p4, an orphaned post. Its author isu0, which never exists because the loop starts at 1.GET /posts/p4?_embed=usercomes back with nouserkey, the same shape you'd get after an account is deleted. Code that readspost.user.namefails here.p5, a draft.publishedAtisnull, not a date. In my test with v1 beta.15,GET /posts?_sort=-publishedAtputp5first, ahead of every dated post, so your "latest posts" list shows the draft at the top unless you filter it out.p6, a busy thread. Comments 1-10 all belong to it, plus any random picks, which is enough to page through.c1, an empty comment body.
The text fields use
getRandomTextWithSpaces,
which produces placeholder-style Latin-like words. That's fine for
layout and edge cases. If your screens need text that reads like your
domain, the template is the place to add it; see
why random isn't realistic test data.
Dates come from getRandomDate
within a fixed range, so a regeneration never produces a post dated
in the future.
Rendered with seed 20261028 and the default counts, the file the
regen script below writes starts like this. Only the first few records
of each collection are shown, and long strings are cut short with
… to fit the page; the real file has the full text.
{
"fixture": {
"seed": 20261028
},
"users": [
{
"id": "u1",
"name": "Andrea Aldbury",
"email": "[email protected]",
"city": "David",
"bio": ""
},
{
"id": "u2",
"name": "Rocco Hammett",
"email": "[email protected]",
"city": "Porto-Novo"
},
{
"id": "u3",
"name": "Kyle Marshwood",
"email": "[email protected]",
"city": "Cairo",
"bio": "fragae judos per ripa in cupidatat littera res adiungo…"
}
],
"posts": [
{
"id": "p4",
"userId": "u0",
"title": "pugna puer ponicus adorior judivus uterque",
"body": "aute ars ingenium aga acuo pietas jectos fluos quaero…",
"publishedAt": "2026-03-31T11:53:44.348Z"
},
{
"id": "p5",
"userId": "u3",
"title": "jectis tacitus tempor fragere mitto agere genus jectalis ",
"body": "feris facire dies validus ecce sum eiusmod utilis reddo…",
"publishedAt": null
}
],
"comments": [
{
"id": "c1",
"postId": "p6",
"userId": "u6",
"body": ""
}
]
}
(The excerpt skips posts p1 to p3 and shows p4 and p5, the
orphan and the draft.)
Why one template rather than a batch
JsonFabrica also has batches, which
generate several saved templates together and return the documents
keyed by alias, as results.<alias>[]. You could make users, posts
and comments three aliases. Batch relations are assigned round-robin,
though, not picked at random, and every alias needs a saved template.
A single template is simpler for a db.json: one file in your repo,
one request, and the planted records sit next to the collections they
belong to.
Regenerating db.json from a seed
The regen script is the only code that talks to JsonFabrica. It reads
the template, sends it with a seed to
POST /v1/templates/generate, and
writes db.json. That endpoint renders a raw template body without
saving anything, so the template stays a file in your repo. Since
anyone running json-server already has Node, the script is plain Node
with the built-in fetch (Node 18 or later):
// scripts/regen-db.mjs
// Regenerates mocks/db.json from mocks/db.tmpl by calling the
// JsonFabrica API. Run it by hand, review the diff, commit the result.
// json-server never runs this script; it only reads the JSON file.
import { readFile, writeFile } from "node:fs/promises";
const api = process.env.JSONFABRICA_URL ?? "https://api.jsonfabrica.com";
const seed = Number(process.env.SEED ?? 20261028);
const res = await fetch(`${api}/v1/templates/generate`, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.JSONFABRICA_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
body: await readFile("mocks/db.tmpl", "utf8"),
seed,
params: { users: 6, posts: 12, comments: 40 },
}),
});
if (!res.ok) {
console.error(`API error ${res.status}: ${await res.text()}`);
process.exit(1);
}
const { data, meta } = await res.json();
// json-server serves "fixture" at GET /fixture, so tests and humans
// can see which seed built the data.
const db = { fixture: { seed: meta.seed }, ...data };
await writeFile("mocks/db.json", JSON.stringify(db, null, 2) + "\n");
console.log(`wrote mocks/db.json (seed ${meta.seed})`);
What it relies on:
- The response is
{ data, meta }.datais the generated document;meta.seedis the seed that was used. - The seed goes into the file. json-server serves any top-level
object as a single resource, so
GET /fixturereturns{ "seed": 20261028 }.meta.generatedAtis left out on purpose: it changes on every call and would make every regeneration show a diff. - Same template, seed and params give the same file. Running the
script twice in a row produces an identical
db.json. Changing the template, the seed or any count changes the random draws, so commit the template and the regenerated file together. - Errors stop the script. A bad template comes back as a 400 with
a code such as
INVALID_TEMPLATEorGENERATION_FAILED, and the script prints it and exits without touchingdb.json.
Run it from the project root with your API key in the environment
(the key goes in an Authorization: Bearer header; see the
API reference), then review and commit:
JSONFABRICA_API_KEY=... node scripts/regen-db.mjs
git diff --stat mocks/db.json
git add mocks/db.tmpl mocks/db.json
Want a different data set for an exploratory session? Run it with
SEED=7 node scripts/regen-db.mjs; git restore mocks/db.json puts
the committed one back. If you lower the counts, keep at least 2
users, 6 posts and 10 comments, or some planted records drop out of
the file.
Serving json-server fake data without dirtying the committed file
json-server writes every POST, PUT, PATCH and DELETE back to
the file it's serving. In v1 it also adds a $schema key on the first
write. If you point it at the committed mocks/db.json, your first
test run leaves a dirty file behind. So serve a copy:
{
"scripts": {
"mock:regen": "node scripts/regen-db.mjs",
"mock:reset": "shx cp mocks/db.json .mock-db.json",
"mock:api": "npm run mock:reset && json-server .mock-db.json -p 3001"
},
"devDependencies": {
"json-server": "1.0.0-beta.15",
"shx": "0.4.0"
}
}
Add .mock-db.json to .gitignore. Every npm run mock:api starts
from the committed data, whatever the last session did. With v1 you
don't even need a restart: it watches the file it serves, so running
npm run mock:reset against a live server reloads the clean data (I
checked this with beta.15). The copy goes through
shx because npm runs scripts
with cmd.exe on Windows, which has no cp; shx cp works the same
on every platform.
Pin an exact json-server version. At the time of writing,
npm install json-server installs the v1 beta (the latest tag
points at it), while the project README still calls 0.17.4 the stable
release and warns that v1 may change. The differences that matter
here:
- Pagination. v1 takes
_pageand_per_pageand returns an object withfirst,prev,next,last,pages,itemsand adataarray. 0.17 takes_pageand_limit, returns a plain array, and puts the total in anX-Total-Countheader. - Parent records. v1 uses singular
_embed(_embed=user); 0.17 uses_expand=user. - Custom routes and middleware. 0.17's CLI has
--routesand--middlewaresoptions. v1's CLI has neither. - Watching the file. v1 reloads the file when it changes, without
a flag, so a regeneration shows up in a running server. 0.17 needs
--watch.
I ran the generated file under both 1.0.0-beta.15 and 0.17.4. The
same db.json works in both, including _embed=comments and the
parent lookup (_embed=user in v1, _expand=user in 0.17).
With the server running, these requests each hit a planted record (shown for v1):
curl "localhost:3001/users/u1?_embed=posts" # "posts": []
curl "localhost:3001/posts/p4?_embed=user" # no "user" key
curl "localhost:3001/posts/p1?_embed=comments" # "comments": []
curl "localhost:3001/posts?_sort=-publishedAt" # draft p5 first
curl "localhost:3001/comments?postId=p6&_page=2&_per_page=5"
curl "localhost:3001/fixture" # { "seed": 20261028 }
The paginated request returned "items": 14 for p6 with this seed:
the ten planted comments plus four random ones.
Using the planted records in frontend tests
Because the planted records have fixed ids, tests can go straight to
them. A Playwright example, assuming your app renders /users/:id and
/posts/:id from the json-server API:
import { test, expect } from "@playwright/test";
test("new user sees an empty profile", async ({ page }) => {
await page.goto("/users/u1"); // planted: no posts, empty bio
await expect(page.getByText("No posts yet")).toBeVisible();
});
test("post with a deleted author still renders", async ({ page }) => {
await page.goto("/posts/p4"); // planted: userId points nowhere
await expect(page.getByText("Unknown author")).toBeVisible();
});
Start json-server before the tests, for example from Playwright's
webServer option or a CI step that runs npm run mock:api in the
background. Because the server always starts from a fresh copy, tests
that create or delete records don't affect the next run. The same file
can feed component work too: the empty profile and the long post make
good Storybook mock data for the same
states.
Faker, json-server 0.17 and a real database
json-server never generated data itself. In 0.17 you could pass it a
JavaScript file that returned the data, which is where many Faker
setups lived; v1 loads .json and .json5 files only. A seeded Faker
script that writes db.json is a fine alternative to a template if
your team prefers code; the workflow is the same: generate once,
commit the file, serve it. And if your frontend talks to a real
backend with a real database, mock the data there instead:
seed the local dev database
rather than standing up json-server.
FAQ
Does json-server generate fake data?
No. json-server serves whatever JSON you give it as a REST API, with
routes, filters, sorting, pagination and relationships, but it does
not invent or randomize records. In 0.17 you could pass a JavaScript
file that returned the data, which is where Faker-based setups ran.
json-server v1 reads .json and .json5 files only, so the data has
to exist before the server starts.
How do relationships work in json-server?
json-server links collections by naming convention. A comment with a
postId field belongs to the post with that id, and a post with a
userId field belongs to that user. Add _embed=comments to a post
request to include its children. In json-server v1, _embed=user
(singular) includes the parent; in 0.17 that was _expand=user.
Why does json-server _embed return an empty array?
Usually the foreign key and the id have different types. json-server
v1 converts numeric ids to strings when it loads the file but leaves
foreign keys alone, so a comment with postId 1 doesn't match a post
whose id is now "1". In 1.0.0-beta.15, a filter like ?postId=1 is
converted to a number, so string foreign keys that look like numbers
don't match filters either. Prefixed string ids such as "p1" work
for both.
How do I reset json-server data between test runs?
json-server writes every POST, PUT, PATCH and DELETE back to the file
it is serving, so a test run changes that file. Commit a clean copy of
db.json, have your start script copy it to an ignored file such as
.mock-db.json, and point json-server at the copy. Restarting the
server then always starts from the committed data.
Should I use json-server v1 or 0.17?
As of October 2026, npm install json-server gives you 1.0.0-beta.15,
because the latest tag points at the v1 beta, while the project
README still calls 0.17.4 the stable version and warns that v1 may
have breaking changes. Either way, pin an exact version in
package.json. The routes, pagination format and relationship
parameters differ between the two. For a new mock API, v1 works if
you pin it; stay on 0.17.4 if you depend on --routes,
--middlewares or a JavaScript data file.
Does JsonFabrica have a json-server package or plugin?
No. JsonFabrica is a JSON-over-HTTP API with no json-server package,
plugin or SDK. You write a small regen script that sends your template
and a seed to the API and saves the generated JSON as db.json.
json-server then serves that file like any other, without knowing
where it came from.
One seeded template gives json-server a db.json with foreign keys
that resolve, edge cases at fixed ids, and a seed that rebuilds the
same file. Rendering a raw template with a seed is a single request
to the JsonFabrica API; the
templates API reference documents the
request body and the { data, meta } response.
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.