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

Source: https://voxgig.com/howto/run-a-local-mock-api-for-frontend-work

- Audience: api-consumer
- Level: beginner
- Languages: typescript, javascript
- Verified: 2026-10-21
- Published: 2026-10-19

## 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`](https://github.com/typicode/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](https://mockoon.com/docs/latest/about/) | 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](https://mswjs.io/docs) | 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](https://mockoon.com/docs/latest/mockoon-data-files/sharing-mock-api-files/)
describes.[^1] Here is the detail route, with a 404 as its default and a 200 that a rule selects.

```json title="mock-api.json"
{
  "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](https://mockoon.com/docs/latest/route-responses/dynamic-rules/) 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](https://mockoon.com/docs/latest/templating/mockoon-request-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"`.

```bash
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](https://github.com/mockoon/mockoon/tree/main/packages/cli) 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`](https://vite.dev/guide/env-and-mode). The screen knows a base URL and three
paths, and gives every call a deadline.

```js title="screen.mjs"
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`](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal/timeout_static)
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.

```bash
node demo.mjs
```

```text output
== 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`](https://mockoon.com/docs/latest/admin-api/global-variables/)
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.

```js title="msw.test.mjs"
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`](https://mswjs.io/api/setup-server) in Node. The pinned release imports `http` and
`HttpResponse` from `msw/http` and `delay` from `msw/utils/delay`; the
[migration guide](https://mswjs.io/docs/migrations/2.x-to-3.x) lists the rest. The option that fails an unhandled request is
[`onUnhandledFrame`](https://mswjs.io/api/setup-server/listen): 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]

```bash
node json-server-demo.mjs
```

```text output
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.

```bash
node --test
```

```text output
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](https://mockoon.com/docs/latest/server-configuration/cors/) 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.

```bash
node pitfall.mjs
```

```text output
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](https://fetch.spec.whatwg.org/#cors-check) applied to the preflight: the header must
be `*` or the exact origin, and the
[preflight](https://fetch.spec.whatwg.org/#cors-preflight-fetch) 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`](https://vite.dev/config/server-options.html#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](/howto/serve-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

- [Serve your OpenAPI examples as mock responses](/howto/serve-openapi-examples-as-mock-responses)

- [Stub outbound HTTP calls in Node tests with nock](/howto/stub-outbound-node-http-calls-with-nock)

- [Mock a generated SDK in your application tests](/howto/mock-a-generated-sdk-in-application-tests)

- [Control which stub wins when mock rules overlap](/howto/control-which-stub-wins-when-rules-overlap)

- [Simulate slow and broken networks with Toxiproxy](/howto/simulate-slow-networks-with-toxiproxy)

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

[^1]: The file opens with `"lastMigration": 33`, a count of the changes the format has been
through. The [CLI README](https://github.com/mockoon/mockoon/tree/main/packages/cli) 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.

[^2]: Every mock the CLI starts carries an admin API at `/mockoon-admin/`, on by default. The
[admin API overview](https://mockoon.com/docs/latest/admin-api/overview/) and the
[CLI README](https://github.com/mockoon/mockoon/tree/main/packages/cli) 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.

[^3]: The `json-server` [repository](https://github.com/typicode/json-server) describes itself
as a full fake REST API with zero coding in less than 30 seconds, with "(seriously)" appended.
The [registry record](https://registry.npmjs.org/json-server) 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.

[^4]: The [CORS check](https://fetch.spec.whatwg.org/#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.