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 editionimport { http, HttpResponse } from 'msw'
const handlers = createHandlers({ http, HttpResponse }, { dataset }) // the resttools/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=500Errors 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/__resetList 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
| Edition | Where |
|---|---|
| React / Vue | createClient({ token }) in src/app/api.ts |
| Next | read/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 |
| Nuxt | server/api/[...path].ts, which becomes a proxy to the real backend |
| HTML | get/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.