The frontend already knows how to create a note, mark it as done, and send it to the trash. The mock doesn't. Every GET /api/notes returns the same array, the POST leaves no trace, and the 409 for a duplicate title is something you have to invent by hand. That's enough for a static list. It falls short as soon as an optimistic UI1, a trash view, or a conflict shows up.
In Mock Your API in 5 Minutes with JSON and Switch Success and Error Without Touching Your Frontend, the mock was a fixed body and a nameResponse you flipped. This is where the store comes in: an in-memory collection, declared in the same JSON, with list, get, create, update, patch, delete, and restore actions. The examples use http-mock-json 6.0.1, and every response in this article comes from actually running it.
This isn't a guide to every filter, relation, or on-disk persist. It's about a mock that remembers what you send.
A store per endpoint
The store is declared on the endpoint, at the same level as GET or POST, not inside a method. You define the collection once (with id and, if you need them, key, seed, template, unique, softDelete, list). Other routes that share that data use the reference form: "store": { "id": "notes" }. On each response that should read or mutate the collection, you set "action" to one of these seven operations:
| Action | What it does | Success | Common errors |
|---|---|---|---|
list | Returns items. Without "list": false, it paginates 10 at a time. Soft-deleted items are omitted unless ?includeDeleted=true | the response statusCode | 400 if the list query is invalid |
get | Loads one item; the key comes from the route params. An item in the trash returns 404, unless ?includeDeleted=true | the response statusCode | 404 |
create | Inserts; merges template + body; if a numeric key field is missing, it generates one | the response statusCode (in the official preset, 201) | 400 (the body is not an object), 409 (conflict) |
update | Full replace (template + body); keeps the key. If the item is in the trash, 404 | the response statusCode | 404 / 400 / 409 |
patch | Partial merge onto the existing item (no template); keeps the key | the response statusCode | 404 / 400 / 409 |
delete | Without softDelete, removes the item. With softDelete, marks it and leaves it in the collection. Always responds 204 with an empty body; the statusCode in the JSON is ignored | 204 | 404 |
restore | Requires softDelete. Clears the deleted mark and returns the item. If it is active or does not exist, 404 | the response statusCode | 404 / 409 |
The 201 on create and the 200 on get or restore are not invented by the store: they come from the statusCode of the selected response. delete is the exception: it is always 204.
The shortest path to a JSON file with persistence, unique, softDelete, and restore is the official preset:
npx mock-server add --preset crud-full That preset leaves seed empty, sets "persist": true, and does not turn off the list engine. The file in this article is the same skeleton, with three deliberate changes: a seed so you can curl right away, "list": false so GET returns the full array, and no persist, so the state lives only for the lifetime of the process.
The notes JSON
mocks/notes.json:
{ "api/notes": { "store": { "id": "notes", "key": "id", "seed": [ { "id": 1, "title": "Comprar leche", "done": false } ], "template": { "id": 0, "title": "", "done": false }, "unique": ["title"], "softDelete": true, "list": false }, "GET": { "nameResponse": "list", "responses": [ { "name": "list", "statusCode": 200, "action": "list" } ] }, "POST": { "nameResponse": "create", "responses": [ { "name": "create", "statusCode": 201, "action": "create" } ] } }, "api/notes/:id": { "store": { "id": "notes" }, "GET": { "nameResponse": "get", "responses": [ { "name": "get", "statusCode": 200, "action": "get" } ] }, "PUT": { "nameResponse": "update", "responses": [ { "name": "update", "statusCode": 200, "action": "update" } ] }, "PATCH": { "nameResponse": "patch", "responses": [ { "name": "patch", "statusCode": 200, "action": "patch" } ] }, "DELETE": { "nameResponse": "remove", "responses": [ { "name": "remove", "statusCode": 204, "action": "delete" } ] }, "POST": { "nameResponse": "restore", "responses": [ { "name": "restore", "statusCode": 200, "action": "restore" } ] } } } key defaults to "id"; here it's explicit. unique is a list of fields: if a value collides with another active item on create, update, patch, or restore, the default response is 409. softDelete: true uses the deletedAt field. The POST on /api/notes/:id does not create: it restores, same as the crud-full preset.
You need Node.js 22.12 or later. Install and start, using 6.0.1's default port:
npm install http-mock-json --save-dev npx mock-server start -p 3001 -f mocks The server listens at http://localhost:3001. CORS is enabled by default. From here on, every curl hits that base URL.
Create, and the 409 when the title already exists
The list starts from the seed:
curl -s http://localhost:3001/api/notes [ { "id": 1, "title": "Comprar leche", "done": false } ] Now a POST with no id:
curl -s -X POST http://localhost:3001/api/notes \ -H 'Content-Type: application/json' \ -d '{"title":"Pagar luz"}' HTTP/1.1 201 Created { "id": 2, "title": "Pagar luz", "done": false } Three things at once. create merges template + body, so done comes out false even though you didn't send it. Because the body had no id, the store generates a number: among the items in the collection it takes the maximum of that field and adds one; if there are none, it starts at 1. Here the seed already had 1, so the new one is 2. If the body (or a route param) already carries the key, that value is kept: a POST with "id": 50 leaves 50.
Repeat the same title:
curl -s -X POST http://localhost:3001/api/notes \ -H 'Content-Type: application/json' \ -d '{"title":"Pagar luz"}' HTTP/1.1 409 Conflict { "message": "Duplicate value(s)", "conflicts": [ { "field": "title", "value": "Pagar luz", "message": "Duplicate value for unique field \"title\"" } ] } That body is the default. You can replace it with a named response, via unique.conflict or a field's conflict, but you don't need to in order to try the "that title already exists" toast. If the body isn't a JSON object (an array, or nothing), the store never even looks at unique: it responds 400 with {"message":"Request body must be a JSON object"}.
patch is not update
PATCH merges the body onto the item that's already there. PUT (update) rebuilds the item as template + body and then forces the original key back on. In both cases the key cannot be changed from the body.
curl -s -X PATCH http://localhost:3001/api/notes/2 \ -H 'Content-Type: application/json' \ -d '{"done":true}' { "id": 2, "title": "Pagar luz", "done": true } The title is still there. A PUT that only sends the title does not keep that done: true: the template sets it back to false.
curl -s -X PUT http://localhost:3001/api/notes/2 \ -H 'Content-Type: application/json' \ -d '{"title":"Pagar el recibo de luz"}' { "id": 2, "title": "Pagar el recibo de luz", "done": false } That's the contrast that matters when the frontend does a minimal patch versus a full replace. If you pick the wrong action, the mock does not "fill in what was already there": update fills in from the template.
Delete, look at the trash, and restore
curl -s -D - -o /dev/null -X DELETE http://localhost:3001/api/notes/2 HTTP/1.1 204 No Content The body is 0 bytes. There is no Content-Type and no Content-Length. That's true with or without softDelete: a successful delete is always an empty 204. The difference is what remains in the collection.
Without softDelete, the item is removed. With softDelete: true, it stays and is marked. The default field is deletedAt, and the value is an ISO 86012 timestamp (new Date().toISOString()). From that point on, list, get, update, and patch treat it as if it weren't there, unless you ask for the trash.
curl -s http://localhost:3001/api/notes [ { "id": 1, "title": "Comprar leche", "done": false } ] curl -s http://localhost:3001/api/notes/2 HTTP/1.1 404 Not Found { "message": "Not found" } A PATCH to the same /api/notes/2 also returns that 404. You ask for the trash with ?includeDeleted=true or ?includeDeleted=1. Any other value, including yes, does not count.
curl -s 'http://localhost:3001/api/notes/2?includeDeleted=true' { "id": 2, "title": "Pagar el recibo de luz", "done": false, "deletedAt": "2026-11-13T14:10:20.572Z" } The deletedAt in that capture is the moment I ran the DELETE. Yours will be different, in the same format. GET /api/notes?includeDeleted=true returns both items: the active one, without that field, and the one in the trash, with it.
Restore is the item's POST:
curl -s -X POST http://localhost:3001/api/notes/2 { "id": 2, "title": "Pagar el recibo de luz", "done": false } The deletedAt field is not set to null: it is removed. A restore on an active item, or on one that does not exist, returns 404 with the same {"message":"Not found"}. Without softDelete on the store definition, that action is not valid: startup is blocked with The "action" "restore" requires "store.softDelete" to be enabled.
The conflict that shows up when you restore from the trash
Items in the trash do not count toward unique or the key. That has a direct consequence, and it's the one that looks most like a real flow: you can create the same title again, and then restore collides.
After the DELETE of note 2, this POST succeeds and gets id 3 (the generator looks at the maximum in the collection, including marked rows, and adds one):
curl -s -X POST http://localhost:3001/api/notes \ -H 'Content-Type: application/json' \ -d '{"title":"Pagar el recibo de luz"}' { "id": 3, "title": "Pagar el recibo de luz", "done": false } Restoring 2, with 3 already active, is no longer a 200:
curl -s -X POST http://localhost:3001/api/notes/2 HTTP/1.1 409 Conflict { "message": "Duplicate value(s)", "conflicts": [ { "field": "title", "value": "Pagar el recibo de luz", "message": "Duplicate value for unique field \"title\"" } ] } That 409 is what lets you try the "a note with that title already exists" dialog when the user takes something out of the trash. If you first send 3 to the trash, the restore of 2 returns 200 again and the list shows notes 1 and 2 once more. 3 is still in the collection, marked, hidden until you ask for includeDeleted.
That's what a stateless mock cannot do. A GET with a fixed body does not know whether the previous POST happened, whether the title is free because the other row is in the trash, or what a second POST to the same /api/notes/2 should return. The optimistic UI, the trash list, and the conflict on restore are not three different mocks: they are the same collection after a sequence of requests.
The list, if you don't turn it off
Since 3.0.0, omitting store.list or setting "list": true turns on the list engine with the same defaults as page-based pagination: pageSize 10, sort id, order asc. The body is still an array, not an object with total or page. Those fields only appear if you put templates in body or headers.
I checked this with 12 items and without "list": false. GET /api/many returned ids 1 through 10. GET /api/many?page=2 returned ids 11 and 12. In the notes for this article I turned that engine off on purpose, so the list would be the full collection while we talked about create, delete, and restore.
Where it fits next to MSW and json-server
They are not the same kind of tool. The useful question is which problem each one solves, not which one "wins."
MSW (Mock Service Worker) is a mocking library for the browser and for Node.js. It intercepts your application's requests — in the browser, with the Service Worker API — and handles them with handlers you write. The client does not point at localhost:3001: it keeps calling the same URL as always, and MSW answers along the way. A handler can always return the same body, or you can keep state in an array or a module. That isn't built in: you write it. The ecosystem also has @msw/data, a separate library for modeling and querying collections (create, find, update, delete, relations, pagination), meant to be used on its own or from those handlers. Its documentation does not describe an HTTP server, or a softDelete with ?includeDeleted=true, or a 409 from unique as a route contract. If you need those, you implement them on top.
json-server is an HTTP server. From a db.json it exposes REST routes for each resource: GET/POST/PUT/PATCH/DELETE. The current official documentation (the main branch, marked as v1 beta) covers filters, sorting, pagination, _embed, and deleting dependents. The default port is 3000. That documentation does not mention unique constraints with 409, or soft delete, or restore. In v1, according to the migration notes in the same README, id is always a string and is generated only if you don't send one. If what you want is generic CRUD over a JSON file, that's the shortest path.
http-mock-json 6.0.1 sits in the middle, but on the server side: it's a CLI that boots HTTP, and your frontend talks to http://localhost:3001 as if it were the API. Routes, status codes, validation, and collection rules are declared in JSON. The store fits when that contract is not just "list and create," but also the 409, the trash, and restore. If what you need is to intercept requests inside the test or the browser, MSW is the tool. If generic REST over a file is enough, json-server is too. If you want a mock of your API, with memory and with those rules, without writing a backend or a handler per verb, this is the gap.
Checklist before you blame the frontend
| Symptom | Check |
|---|---|
The POST creates, but the GET stays the same | The GET response has no "action": "list", or the store.id is not the same on both routes |
create does not assign an id | The body (or a route param) already has that key: in that case it is not generated |
| You expected a full array and only see 10 items | Since 3.0.0 the list engine is on by default; set "list": false or request ?page=2 |
The DELETE is not the status you put in the JSON | A successful delete is always 204; statusCode is ignored |
?includeDeleted=yes does not show the trash | Only "true" and "1" count |
restore returns 409 and the item "was no longer there" | There is another active item with the same unique value; rows in the trash do not occupy that value |
The PUT cleared a field you didn't send | update fills in from the template, not from the previous item; for a partial change, use patch |
POST /api/notes/:id creates another note | In this design that POST is restore, not create; create lives on the collection |
A mock with memory does not replace the backend. It lets the frontend walk the full flow — create, collide, send to the trash, and recover — while that backend still doesn't exist, or isn't in a position to give you those cases on demand.
References
- http-mock-json on npm · repo · version 6.0.1 · Store reference in the README
- Previous: Mock Your API in 5 Minutes with JSON · Switch Success and Error Without Touching Your Frontend
- CLI:
mock-server add --preset crud-full(collection + item,unique,softDelete,restore);mock-server startlistens on port 3001 by default - MSW, Introduction ·
@msw/data - json-server, official README (the
mainbranch, v1 beta documentation)
Notes
-
Optimistic UI: the client updates the screen as soon as the user acts, without waiting for the server's response, and corrects itself if that response fails. To test it properly, the mock has to accept the change, return the created item, and, on error, a 409 or a 404 the client can handle. ↩
-
ISO 8601 is the standard date and time format. JavaScript's
Date#toISOString()writes it in UTC, with aZsuffix: for example,2026-11-13T14:10:20.572Z. ↩


