Skip to content
Mock API and real backend

Mock API and real backend

In a full edition, use the demo toolbar to inspect loading, empty, and error states before replacing the sample data. The shared router defines the response contract, but it is not a production backend. In a full repository, change common code under shared/mock/ and sync; in a standalone edition, its shared mirror is yours to edit. A starter has no demo data or mock API, so connect it directly to your own service. See generated folders for the mirror paths, and roles for the server-side permission contract.

One API contract across five editions

The HTML edition has no bundler, no service worker and no npm dependency, so it cannot use MSW. It still has to page, sort, filter and search exactly as the four framework editions do, or the demo a buyer clicks through is a different product in each package.

So the routing and the response shapes live in api.mjs, which imports nothing outside this folder. MSW is a thin adapter over it, and the HTML edition wraps the same function in something shaped like fetch:

import { createApi, createFetchLike } from './api.mjs'
const api = createApi({ dataset, timeseries, enums })
const fetch = createFetchLike(api)          // HTML edition
import { http, HttpResponse } from 'msw'
const handlers = createHandlers({ http, HttpResponse }, { dataset })  // the rest

tools/mock-data/verify.mjs runs a dozen requests down both paths and fails if a single byte differs.

msw is passed in rather than imported for the same reason IntlMessageFormat is: shared/ sits outside every edition's node_modules, and each edition owns its own copy at its own version.

Inspect loading, empty, and error states

Loading, empty and error states are most of the work in an admin panel and the first thing a buyer looks for, and they are exactly the states you cannot reach by clicking around a dataset that always answers instantly and always has rows.

Globally, through the demo toolbar:

demo.set({ delay: 1500 })      // watch the skeletons
demo.set({ empty: true })      // every list answers with nothing
demo.set({ error: 503 })       // every request fails
demo.set({ failureRate: 0.3 }) // one in three fails, for retry logic
demo.reset()

Per request, which is what the documentation shows a buyer:

/api/orders?__delay=2000
/api/orders?__empty=1
/api/orders?__error=500

Errors always answer with the same body, so the interface has one thing to render and one thing to translate:

{ "error": { "status": 404, "code": "error.notFound", "detail": null } }

POST /api/__reset puts the fixtures back after a visitor has edited the demo into nonsense.

Endpoint and list parameters

GET    /api/{orders|products|customers|deals|threads|staff|events}
GET    /api/{collection}/:id
PATCH  /api/{collection}/:id
POST   /api/deals/:id/move          { stage, position }
POST   /api/threads/:id/messages   { body }
GET    /api/notifications
POST   /api/notifications/:id/read
POST   /api/notifications/read-all
GET    /api/search?q=&locale=
GET    /api/calendar?from=&to=
POST   /api/events                  { title, kind, date, endDate, allDay, startTime, endTime, ownerId, location, notes }
DELETE /api/events/:id
GET    /api/metrics/summary?days=30
GET    /api/metrics/timeseries?days=30 | ?from=&to=
GET    /api/metrics/analytics?days=7|30|90|365
GET    /api/enums
POST   /api/auth/login              { email, password }
POST   /api/auth/logout
GET    /api/auth/session            still needs an identity; exempt from the demo switches
POST   /api/__reset

List parameters: page, perPage, sort, order, q, locale, filter[field]=a,b, from, to. Two values in one filter are a union; an empty filter filters nothing, which is what an untouched filter chip should do. A page past the end clamps rather than emptying, so a filter cannot strand the reader on page 7 of 1.

Sorting a coded field uses the rank in enums.json, never the label. Sorting by a translated label reorders the table when the language changes, which is disorienting and looks like a bug.

POST /api/deals/:id/move takes the card out of its column, splices it into the target column at the clamped position and renumbers both columns. A deal that changes stage takes that stage's probability from stages.mjs — ten percent for a lead up to a hundred for a win — so the pipeline's weighted forecast follows the board. shared/ui/board.mjs applies the same rule before the answer lands, and tools/mock-data/verify.mjs checks the two agree move for move.

GET /api/metrics/analytics is the Analytics screen's report, computed by analytics.mjs: whole calendar days ending on the last day the traffic series covers, bucketed by day, week (from Monday) or month, with every change measured against the window just before. A window the series does not fully cover has no change rather than a flattering one. Revenue by category shares each order's base-currency total across its lines, so the categories add up to the revenue figure.

GET /api/calendar?from=&to= is every event that touches the range — an all-day event that starts before it and ends inside it included — sorted by day and time, each with its owner; a missing or impossible date answers 422. POST /api/events and PATCH /api/events/:id hold an event to the same schema the dialog uses, shared/forms/event.mjs, and answer 422 with the refused fields as catalogue keys; an owner who is not on the staff is refused too. What is saved is normalised: an all-day event has no times, a timed one ends on the day it starts. A new event takes the next id in the sequence, which is what lets the browser's journal replay it into the same id after a reload.

The events are generated with a seed of their own, so adding them left every other file byte-identical: weekday meetings, calls, deadlines, launches and deliveries from July to November 2026, plus three placed on and after the demo day — two overlapping meetings on the 22nd and a three-day fair — that the walks read.

The inbox's list is GET /api/threads, searched through each thread's customer name, its order number and the text of every message in it, and answered with the last message as a preview. GET /api/threads/:id carries the whole conversation, oldest first, each staff reply with who wrote it. POST /api/threads/:id/messages is a reply from the signed-in staff member, held to shared/forms/message.mjs; it is dated a minute after the thread's last message, or at noon on the demo day when that is later — never by the reader's clock — and it marks the thread read. PATCH /api/threads/:id sets the status, the priority, the assignee and whether it has been read, and nothing else.

The messages carry no random draws at all. The first is always the customer's, about the thread's subject; an open thread ends with the customer, a waiting or resolved one with the shop; each reply is chosen by what came before it, and all of them are in the language the customer writes in.

GET /api/notifications is every notification the bell lists, newest first, with the unread count beside them. Each carries the few fields of its record its sentence needs — an order's number and customer, a product's name and stock, a thread's subject, a customer's name — read as the record is now, and null when the record is gone. POST /api/notifications/:id/read reads one and POST /api/notifications/read-all reads the rest; both answer with the new count. There is no collection route for them: nobody filters, sorts or edits a notification.

The notifications carry no random draws either. Each is a record that already exists — the four newest unfulfilled orders, three products running low, the four threads with an unread message, the three newest customers — dated when that record says it happened; only the stock alerts, which have no moment of their own in the data, are dated a fixed few hours back. The ones newer than fifty hours before the demo's instant start unread, which is five.

GET /api/search?q=&locale= answers grouped, ranked orders, products and customers in one request — the topbar's combobox and the command palette's live record results both call it. A query shorter than two folded characters answers every group empty rather than refusing the request: a short query is a normal state, not an error. Matching and ranking are shared/search/match.mjs's rules, reused for the command palette's own list of pages and actions; this file only decides which fields of which collection are checked, and how ties break once the rank agrees — an order's placedAt descending, a product's localised name, a customer's name, each then by id.

PATCH accepts only fields the collection declares sortable or filterable and answers 422 naming the others. Dragging a card is not a field update — the columns either side have to close up — so the board gets its own endpoint.

POST /api/auth/login checks nothing beyond "both fields present" and "at least six characters", and picks the staff member to sign in as by email, case-insensitively; an address that matches nobody signs in as the owner. A template that ships a real credential check ships a credential; deleting all of this is a buyer's first job.

HTML: local data over file URLs

html/partials/foot.html loads the inline dataset from html/shared/mock/dataset.inline.js and the shared-code bundle. html/bundle.mjs exposes the mock modules to classic scripts; html/assets/js/app.js creates the dataset, mock router, browser mutation journal, and fetch-shaped request function. To connect your own API, replace the get/send request path and the demo session logic in html/assets/js/app.js; remove demo-only data and scripts when you no longer need them (see remove the demo). In a standalone HTML folder these files are inside that folder, and its shared/ is editable rather than synced from the full repository.

React: browser request adapter

MSW, behind a dynamic import (src/app/mocks/). Every screen talks to a real URL through real fetch and knows nothing about the mock, so pointing the app at a server is a base URL change and a deletion.

The handlers are not written here: shared/mock/api.mjs is the router — the same one the HTML edition bundles — and shared/mock/handlers.mjs is a twelve-line adapter. The dynamic import is what keeps MSW and the 1.2 MB dataset in their own chunk rather than in the one every screen needs.

A visitor's changes are kept in this browser: shared/mock/journal.mjs writes each request that changed something to localStorage and replays them over the fixtures on the next load, so a saved setting survives a reload. The demo bar's Reset demo button empties the journal. Where storage is blocked the demo simply runs in memory.

Vue: browser request adapter

MSW, behind a dynamic import (src/app/mocks/). Every screen talks to a real URL through real fetch and knows nothing about the mock, so pointing the app at a server is a base URL change and a deletion.

The handlers are not written here: shared/mock/api.mjs is the router — the same one the HTML edition bundles — and shared/mock/handlers.mjs is a twelve-line adapter.

A visitor's changes are kept in this browser: shared/mock/journal.mjs writes each request that changed something to localStorage and replays them over the fixtures on the next load, so a saved setting survives a reload. The demo bar's Reset demo button empties the journal. Where storage is blocked the demo simply runs in memory.

Next.js: server request adapter

src/server/api.ts calls the shared mock router directly. There is no HTTP hop, no MSW and no fetch to localhost for a server component: a server asking a server for its own data over the network is a round trip that exists only to look realistic. Swapping in a real backend is a change to that one file.

The one exception is the topbar's search box and the command palette (src/layout/search.tsx, src/layout/palette.tsx): a client island answering a keystroke has no server component to call read() through, so it fetches GET /api/search — see "Search and the command palette" below for why that is a route handler and not a server action.

Next compiles route handlers and server actions into separate bundler layers, each with its own copy of every module it imports, so the mock dataset is built once behind globalThis (src/server/api.ts) rather than as a plain module-level constant — otherwise the route handler above would read a dataset a server action's write never reached.

The demo switches (slow, empty, error) travel as query parameters — ?__delay=1200, ?__empty=1 — because a server render has no client store to read. That also makes every demo state a shareable URL.

Nuxt: server request adapter

server/api/[...path].ts is one handler for every endpoint, over server/utils/mock.ts. The router is not written there — shared/mock/api.mjs is the same one the HTML edition bundles and the other editions run — so this edition needs no MSW at all: it has a server, and the mock runs on it. Nothing in the application knows the backend is a mock; replacing it is deleting server/ and pointing the base URL elsewhere.

Where the token reaches the API

EditionWhere
React / VuecreateClient({ token }) in src/app/api.ts
Nextread/write/respond in src/server/api.ts — the same cookie also reaches app/api/search/route.ts, the one endpoint this edition's browser calls directly
Nuxtserver/api/[...path].ts, which becomes a proxy to the real backend
HTMLget/send in assets/js/app.js

Connect your own backend

For React and Vue, replace the mock service worker and set API_BASE in react/src/app/config.ts or vue/src/app/config.ts to your backend. For Next.js and Nuxt, replace the server-side mock adapter with a real service or proxy and enforce authorization on that service; hiding a client control is not enforcement.

If changing the common React/Vue request shapes in a full repository, edit shared/api/client.mjs and sync; in a standalone folder, edit its own mirrored copy. The included edition sections give the specific entry points; remove the demo gives the deletion checklist.