The invoices screen is due this sprint, but the billing endpoints it calls are still being written. You paste a JSON array into the component to keep going. Every state the screen needs, an empty list, a slow report, or a 503 from billing, is a code path you cannot reach until the backend ships.
What you get
You will end up with a committed Mockoon environment serving three invoices, a 404, a switchable 503, and a slow report, plus a text rendering of the screen in each state. This is for you if your frontend is ahead of its backend.
Short answer
Describe the routes, status codes, and delays your screen needs in one Mockoon environment
file, commit it, and start it headless with mockoon-cli start --data mock-api.json --port 3000. Point the app’s API base URL at that port and load the screen. Switch the mock into an
outage or a slow response from the admin API instead of editing the app. Turn Mockoon’s
automatic CORS off and copy the headers the backend sends, so the mock refuses what staging
will refuse.
You will need
Node 22.12 or later, which MSW requires, the Mockoon CLI as a development dependency, and the
paths and response shapes the screen will call. Verified 2026-10-21 against Node 22.22.0, @mockoon/cli 9.9.0, json-server 1.0.0-beta.15, and
msw 3.0.3. The screen is rendered as text in Node, so no browser runs.
Approaches compared
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
json-server | CRUD over a JSON file, up in one command, for a screen that lists, creates, and edits records | Every write lands in the file it serves, ids are chosen by the server, and status codes and delays are not yours to script | The screen needs an error state, a slow state, or a shape the file cannot express |
| Mockoon | A mock the whole team runs from one committed file, with rules, latency, and templating | A desktop app or a hand-edited JSON file to maintain, a second process on a port, and defaults kinder than any real API | The mock only ever runs inside one test suite, where an in-process interceptor starts faster |
| MSW | Handlers in the app’s own language, shared by browser and Node tests, with no port | A service worker to register, plus handlers only the JavaScript app can reach, so curl and a native client get no mock | Someone outside the JavaScript app needs the same mock, or the HTTP client should run over a real socket |
json-server gives you CRUD in one command and writes every POST back into db.json, so the
fixture you committed changes under you. MSW answers from inside the app, which makes it the
fastest of the three to start and the one curl cannot reach. Mockoon costs a second process on a
port and gives you a file that scripts status codes and delays the other two leave to code.
Describe the routes, states, and delays in one file
A Mockoon environment is one JSON file: the port, the headers every response carries, and a list of routes, each with at least one response. This one was written by hand; the desktop application writes the same shape, and the CLI accepts either, as the data files documentation describes.1 Here is the detail route, with a 404 as its default and a 200 that a rule selects.
{
"uuid": "1a000000-0000-4000-8000-000000000002",
"type": "http",
"method": "get",
"endpoint": "invoices/:id",
"responses": [
{
"uuid": "1a000000-0000-4000-8000-000000000021",
"label": "unknown id",
"statusCode": 404,
"default": true,
"body": "{\"error\":\"invoice not found\"}"
},
{
"uuid": "1a000000-0000-4000-8000-000000000022",
"label": "in_1002",
"statusCode": 200,
"body": "{\"id\":\"in_1002\",\"customer\":\"Globex\",\"amount\":450,\"currency\":\"EUR\",\"status\":\"open\"}",
"rules": [
{ "target": "params", "modifier": "id", "value": "in_1002", "operator": "equals", "invert": false }
],
"rulesOperator": "OR"
}
]
},
The response marked default answers when no rule matches, so every id other than in_1002
gets the 404. A rule compares
one part of the request, here the :id path segment, against a fixed value or a regular
expression pattern. The list route uses the same shape with a rule on global_var, a variable
the admin API sets from outside the app. The aging report route carries "latency": 1500, a
delay in milliseconds. The POST route builds its body with
templating helpers such
as {{body 'customer'}}.
The environment also sets "cors": false, two headers every response carries, and an
OPTIONS /invoices route. Those three are the subject of the failure section.
Start it headless and point the screen at it
The CLI runs the file in the foreground and writes one JSON log line per request to stdout,
beginning with "message":"Server started on port 3000".
npx mockoon-cli start --data mock-api.json --port 3000 --disable-log-to-file --admin-api-token local-dev
--disable-log-to-file keeps the log out of your home directory, and --admin-api-token fixes
the token the admin API otherwise generates and prints at startup, as the
CLI README describes.2 Point the
app at the port. In a Vite project that is one line in .env.development,
VITE_API_BASE_URL=http://127.0.0.1:3000, read through
import.meta.env. The screen knows a base URL and three
paths, and gives every call a deadline.
export function describe(result, noun) {
if (result.status === 'timeout') return `${noun} is taking longer than usual, still loading`
if (result.status === 'unreachable') return `${noun} could not be reached, check your connection`
if (result.status === 404) return `${noun} not found`
if (result.status >= 500) return `${noun} unavailable right now, try again in a minute`
return `${noun} failed with status ${result.status}`
}
// A fetch with a deadline. A slow route becomes a `timeout` result instead of a spinner
// that never stops, and a server that is not there becomes `unreachable`.
export async function call(url, timeoutMs) {
try {
const res = await fetch(url, { headers: { accept: 'application/json' }, signal: AbortSignal.timeout(timeoutMs) })
const text = await res.text()
let body = text
try { body = JSON.parse(text) } catch { /* not JSON, keep the text */ }
return { status: res.status, body, contentType: res.headers.get('content-type') }
} catch (err) {
return { status: err.name === 'TimeoutError' ? 'timeout' : 'unreachable', body: null }
}
}
AbortSignal.timeout
turns the 1500 ms route into a timeout result at 500 ms, which is the state a loading indicator
is built from. The demo starts the CLI on a free port, renders the screen four times, and flips
the outage scenario between the first two.
node demo.mjs
== mock at http://127.0.0.1:40993, the screen as built
INVOICES
in_1001 Acme Ltd 1200 EUR paid
in_1002 Globex 450 EUR open
in_1003 Initech 9800 EUR overdue
DETAIL in_1002
Globex, 450 EUR, open
AGING
Aging report is taking longer than usual, still loading
== scenario set to outage through the admin API, app unchanged
INVOICES
Invoices unavailable right now, try again in a minute
AGING
Aging report is taking longer than usual, still loading
== an invoice the mock has no response for
INVOICES
in_1001 Acme Ltd 1200 EUR paid
in_1002 Globex 450 EUR open
in_1003 Initech 9800 EUR overdue
DETAIL in_9999
Invoice not found
AGING
Aging report is taking longer than usual, still loading
== the same screen with a 2000 ms budget for the 1500 ms report
INVOICES
in_1001 Acme Ltd 1200 EUR paid
in_1002 Globex 450 EUR open
in_1003 Initech 9800 EUR overdue
AGING
0-30 days 450
31-60 days 0
over 60 days 9800
The first screen is the one you build against: three rows, one detail, and a report that has
not arrived after 500 ms because the route holds it for 1500. The second follows one
PUT /mockoon-admin/global-vars
with {"key":"scenario","value":"outage"}, which the rule on the list route reads, so the app is
left as it is. The third shows the 404 path, and the fourth widens the budget to 2000 ms and the
buckets arrive.
Run the same screen against MSW, and the file against json-server
MSW keeps the mock inside the process: handlers answer fetch before a request reaches the
network, so no port is open and curl cannot reach it.
const API = 'https://api.example.com'
export const handlers = [
http.get(`${API}/invoices`, () => HttpResponse.json(db.invoices)),
http.get(`${API}/invoices/:id`, ({ params }) => {
const invoice = db.invoices.find((i) => i.id === params.id)
return invoice ? HttpResponse.json(invoice) : HttpResponse.json({ error: 'invoice not found' }, { status: 404 })
}),
http.get(`${API}/reports/aging`, async () => {
await delay(1500)
return HttpResponse.json({ buckets: [{ range: '0-30 days', total: 450 }] })
}),
]
const server = setupServer(...handlers)
before(() => server.listen({ onUnhandledFrame: 'error' }))
after(() => server.close())
The same handlers go to setupWorker in the browser and to
setupServer in Node. The pinned release imports http and
HttpResponse from msw/http and delay from msw/utils/delay; the
migration guide lists the rest. The option that fails an unhandled request is
onUnhandledFrame: set it to error, or a request no
handler covers leaves for the real network.
json-server is the other direction: a file becomes a REST API with no handlers at all.3
node json-server-demo.mjs
GET /invoices 200 3 invoices, the file holds 3
POST /invoices 201 id chosen by the server: true
GET /invoices 200 4 invoices, the file holds 4
GET /invoices/in_9999 404 {"error":"Not Found"}
access-control-allow-origin on every response: *
keys json-server added to the file: $schema
The POST was written back to the served file before the response arrived, with an id the server
invented and a $schema key the server added. That is a small database with a REST surface,
which suits a screen that creates and edits records. A 503 or a delay has no place in that file.
Check it worked
Run the three test files together.
node --test
1..12
# tests 12
# suites 0
# pass 12
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 5577.520404
Twelve tests: six against the Mockoon process, one on the escaped invoice path, two against MSW,
and three on the CORS verdict.
One asserts application/json; charset=utf-8 on the list and
Access-Control-Allow-Origin: https://app.example.com on a preflight, the subject of the next
section. The summary line to compare against is # pass 12.
When it goes wrong
The screen works against the mock from the Vite development server but fails against staging
with blocked by CORS policy in the console. Mockoon’s cors setting is on by default, so every
preflight gets a 200 and Access-Control-Allow-Origin: *, as the
CORS page documents, and the mock
accepted an origin the backend never will. The committed file turns it off, copies the headers
the backend sends, and adds the OPTIONS /invoices route the backend answers.
node pitfall.mjs
Mockoon defaults: cors true, no environment headers
OPTIONS /invoices 200 access-control-allow-origin: *
a browser at http://localhost:5173: allowed
GET /invoices 200 content-type: text/html; charset=utf-8
mock-api.json: cors false, headers copied from the backend
OPTIONS /invoices 204 access-control-allow-origin: https://app.example.com
a browser at http://localhost:5173: blocked, the header names https://app.example.com
GET /invoices 200 content-type: application/json; charset=utf-8
The verdict line is the Fetch standard’s
CORS check applied to the preflight: the header must
be * or the exact origin, and the
preflight itself must get a 2xx status.4
With the backend policy in the mock, the development server’s origin is refused in the first week
rather than at the staging demo. Fix it where production fixes it. If the app and the API share
an origin in production, proxy /api to the mock with Vite’s
server.proxy, so the browser skips
the CORS check in both places. If they are cross-origin, the allow-list of the backend is the
thing to change.
The second failure is in the same output: with no header set in the environment, Mockoon answers
Content-Type: text/html; charset=utf-8 for a JSON body. response.json() still parses it, so
the mismatch with the real API goes unnoticed. Set Content-Type once in the environment
headers, as mock-api.json does.
The third is json-server writing to its file. Every POST, PUT, PATCH, and DELETE lands in
db.json, so a fixture that doubles as a test expectation changes the first time someone saves a
record. Serve a copy, as the demo does.
When not to do this
Do not keep the screen on the mock once the real endpoints exist. Point the app at the backend as soon as it answers, and keep the mock for the states the backend cannot produce on demand, such as a 503 from billing.
Do not put scenario switches into the app. A header or a query parameter that selects a mock response is code that ships to production and does nothing there. The switch belongs in the mock, which is what the global variable and the admin API are for.
Do not hand-write the routes when an OpenAPI document for the API exists. Mockoon imports one, and serving your OpenAPI examples as mock responses keeps the mock in step with the document instead of with a developer’s memory of it.
Do not run json-server against the only copy of a fixture. Do not pick MSW when a native client
or a colleague’s curl needs the same mock, because a handler inside the JavaScript app is
unreachable from both.
Related how-tos
Last verified
Verified 2026-10-21 against Node 22.22.0, @mockoon/cli 9.9.0, json-server 1.0.0-beta.15, and
msw 3.0.3. Every output block is what the command preceding it printed, run in the page’s code
directory after npm ci. The screen was rendered in Node rather than in a browser, so the CORS
verdicts come from cors.mjs, written from the Fetch standard. The Mockoon desktop application
and MSW’s service worker were not run.
Footnotes
-
The file opens with
"lastMigration": 33, a count of the changes the format has been through. The CLI README says the CLI migrates a file from an older Mockoon in memory, as a copy, and leaves the file you provide untouched. A file from a more recent application calls for updating the CLI instead. ↩︎ Back to text -
Every mock the CLI starts carries an admin API at
/mockoon-admin/, on by default. The admin API overview and the CLI README both say bearer authentication is always on, with a token generated and printed in the logs when none is supplied.--disable-admin-apiremoves it. ↩︎ Back to text -
The
json-serverrepository describes itself as a full fake REST API with zero coding in less than 30 seconds, with “(seriously)” appended. The registry record lists thirty-seven numbered alpha and beta releases on the way to 1.0.0. The README calls the beta usable and points anyone who wants stability at v0.17.4, from September 2023. ↩︎ Back to text -
The CORS check runs to eight steps, and the wildcard passes at step three only when the request’s credentials mode is not
include. A request that carries a cookie goes on to step four, where*is compared byte for byte with the origin and fails. A mock that answers*is therefore permissive for anonymous requests and wrong for the first request made after logging in. ↩︎ Back to text