How-to › Test and mock integrations

How to run a local mock API for frontend development with Mockoon#

Describe routes, status codes, and delays in one Mockoon environment file, run it headless with the CLI, and point the screen you are building at it.

Audience
API consumer
Level
beginner
Topic
Mock an API
Languages
TypeScript and JavaScript
Verified

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

ApproachWhen it fitsWhat it costs youWhen to pick something else
json-serverCRUD over a JSON file, up in one command, for a screen that lists, creates, and edits recordsEvery write lands in the file it serves, ids are chosen by the server, and status codes and delays are not yours to scriptThe screen needs an error state, a slow state, or a shape the file cannot express
MockoonA mock the whole team runs from one committed file, with rules, latency, and templatingA desktop app or a hand-edited JSON file to maintain, a second process on a port, and defaults kinder than any real APIThe mock only ever runs inside one test suite, where an in-process interceptor starts faster
MSWHandlers in the app’s own language, shared by browser and Node tests, with no portA service worker to register, plus handlers only the JavaScript app can reach, so curl and a native client get no mockSomeone 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.

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

  1. 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

  2. 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-api removes it. ↩︎ Back to text

  3. The json-server repository 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

  4. 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

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Listen to Fireside, the Voxgig podcast

Since 2018, Richard Rodger has talked with developer advocates, event organizers, and the people who build developer-facing companies about the specifics of the work.