Admin panel and the write API
/admin is a working back office: products, inventory, categories, orders, storefront settings, import and export.
It is also the reference implementation of the write API. Every endpoint documented here has a screen driving it, so the contract is proven rather than asserted.
npm run dev → http://localhost:5173/adminTwo tabs open
Keep the admin in one tab and the shop in another — the shop follows along.
The demo backend is localStorage, which is shared between tabs, and the browser announces a write to *the other* tabs with a storage event. Adopting that data was never the hard half; the hard half is that the layers above it do not know they have gone stale. So a foreign write does two more things:
- The read cache is dropped. A local write knows exactly which namespaces it
invalidated and purges those (PURGES in src/lib/api/index.js). A write from another tab arrives as an opaque blob and nothing here knows what it touched, so the honest response is to assume the catalogue is stale.
- Mounted pages are woken. Emptying a cache only helps the *next* call, and
a shop page sitting open makes no next call. Every useAsync re-reads silently, so a correction never flashes a skeleton over content someone is reading.
The same applies to the bag, saved items and the session: adding to the bag, filling a heart or signing out in one tab reaches the others. Signing out is the one worth calling out — a tab still showing an account menu, an order history and a saved address for somebody who has left is the disclosure that order scoping was fixed to prevent, arriving through a different door.
In api mode none of these keys exist, no event ever names them, and every subscription is inert. Cart and session are the server's business then.
How it relates to the storefront
Both talk to the same api surface.
- Mock mode — writes go to a local database (
src/lib/db.js, backed by
localStorage). Edit a price in admin and the storefront shows it immediately, in that tab and in every other open tab. There is no publish step, because there is nothing to sync.
- Api mode — the same calls hit your
/admin/*endpoints. The panel does
not change.
That symmetry is the point. If the admin panel can drive your backend, so can anything else.
What it manages
| Screen | Does |
|---|---|
| Overview | Counts, low and out-of-stock, orders, revenue |
| Products | Search; click a row to open the full record |
| Product record | Six tabs: details, media, variants, fit and fabric, highlights and specs, organise. Create and delete |
| Inventory | Every variant, filterable to low or out, adjust by delta |
| Categories | Tree with parents and children, create and re-parent |
| Size charts | Shared measurement tables |
| Orders | Status, tracking, cancellation with stock return |
| Discounts | Codes, percentage, fixed or free shipping |
| Storefront | Company profile, currency, features, recommendations, checkout, trust |
| Import / export | Whole-catalogue JSON, the shape the bulk endpoint takes |
| Developer docs | This documentation, rendered, with copy buttons |
Derived on write, not stored
Three things the server computes rather than trusting a client to send:
- `badges` —
salefrom compare-at,sold-outandlow-stockfrom the
variants. A product that sells out in admin must turn over on the grid, which it will not do if badges were computed once at import.
- `available` — always
inventory > 0. - Variant price — a product price change cascades to every variant that was
not individually overridden. Without it the grid shows the new price and the cart charges the old one, with nothing on screen to warn anyone.
Drafts
Product.published: false hides a product from GET /products, from search, from recommendations, and makes its own URL return 404. Admin still lists it. This is what lets a season be staged before it opens.
The write API
All routes are namespaced under /admin and require authentication. These are not public endpoints — a storefront token must never be able to reach them.
Products
GET /admin/products?q=&page=1&per_page=25 → { items, total, page, perPage }
POST /admin/products → Product
PATCH /admin/products/:id → Product
DELETE /admin/products/:id → { ok: true }POST and PATCH take a partial Product. Anything omitted is left alone, which is what makes a price-only edit safe.
PATCH /admin/products/prod_1
{ "price": { "amount": 17800, "currency": "USD" } }A product price change must cascade to its variants unless a variant has an explicit override. The admin editor does this; if your backend does not, you will sell at last month's price the moment anyone adds to cart.
Inventory
PATCH /admin/variants/:variantId/inventory { quantity } → Variant (set)
POST /admin/variants/:variantId/inventory { delta } → Variant (adjust)Prefer the delta. Two people adjusting the same SKU with set silently overwrite each other; with a delta both land. It is also the only shape that survives a replayed webhook without double-counting — key on an operation id and the retry is a no-op.
POST /admin/variants/var_merino_oat_m/inventory
{ "delta": -1, "reason": "sale", "operationId": "evt_9f2c" }available is derived from inventory > 0 server-side, never sent by a client.
Categories
POST /admin/categories { slug, name, parent, blurb } → Category
DELETE /admin/categories/:slug → { ok: true }Deleting a category promotes its children to the deleted node's parent rather than orphaning them. A category tree with unreachable nodes is worse than a flat one.
Settings
PATCH /admin/storefront → the storefront documentDeep-merged, except arrays which replace wholesale — see CONFIGURATION.md.
Media
POST /admin/media multipart/form-data, field name "file"
→ { id, url, type, width, height, duration?, bytes }
GET /admin/media → { items, total }
DELETE /admin/media/:idMultipart, not JSON. A base64 body is a third larger and holds the whole file in memory twice.
type is image or video. Return width and height — the storefront puts them on the element so the grid does not reflow while a shot decodes, which is the main source of layout shift on a catalogue page.
The product then stores whatever url you returned. Nothing downstream cares where it points.
In mock mode uploads go to IndexedDB, images are re-encoded to a 1600px WebP, and the product stores a media:<id> reference. That is right for a demo — it survives a reload and holds a few dozen files — and wrong for a shop, which is why the same button posts to your endpoint the moment VITE_DATA_SOURCE=api.
Normalise on write
A first integration will POST a product with a title, a slug and a price and nothing else. Fill the rest in:
tags [] badges [] details [] care [] categories []
images [] variants [] options [] swatches {}
rating { average: 0, count: 0 } published true createdAt now()Defaulting once at the write boundary is what stops a missing rating surfacing three screens away as a crash inside a sort comparator — which takes down the whole listing rather than one card.
Bulk
POST /admin/import
{
"mode": "merge",
"products": [ /* Product[] */ ],
"categories": [ /* Category[] */ ],
"collections": [ ],
"settings": { }
}
→ { products: 24, categories: 20, collections: 3 }
GET /admin/export → the same shapemode: "merge" upserts by slug and deletes nothing. mode: "replace" swaps the whole set.
This is the endpoint a nightly ERP dump should use. A thousand individual POSTs is a thousand transactions, a thousand cache purges and a rate limit you will hit. One payload is one transaction and one purge.
For very large catalogues, chunk it — a few thousand products per request — and make each chunk idempotent so a partial failure can be retried safely.
Authentication
POST /admin/auth/login { username, password } → { token }The demo checks VITE_ADMIN_USER / VITE_ADMIN_PASSWORD — admin / admin by default — entirely in the browser. Those are compiled into the bundle like every VITE_ variable, so they are public by construction. That is a demo affordance, not a design; the customer sign-in at POST /auth/login is a separate thing with a separate session.
For a real deployment:
- Separate the admin session from the storefront session. A customer token
must not carry admin scope. Different audience claim, ideally a different cookie domain.
- Authorise on the server, on every request. Hiding
/adminin the client
hides nothing — the route is in the JavaScript bundle either way.
- Require a second factor. Admin access is full catalogue and full order
history.
- Rate-limit and log writes with the actor, so "who dropped the price to
zero at 3am" has an answer.
If you would rather not expose an admin surface at all, do not implement /admin/*. Manage the catalogue in your existing system and push to the storefront with the bulk endpoint and webhooks — the storefront never needs to know an admin panel exists.
Webhooks out
When something changes, tell the storefront so it can purge rather than wait for a TTL:
POST https://your-store.example/api/revalidate
{ "type": "product.updated", "slug": "merino-crew-knit", "at": "2026-09-09T…" }| Event | Purge |
|---|---|
product.updated product.deleted | that product, /products lists, /bootstrap |
inventory.updated | that product, /products lists |
category.updated | /categories, /products, /bootstrap |
settings.updated | /storefront, /bootstrap |
order.paid order.fulfilled | that order |
Sign the payload and verify the signature. An unauthenticated revalidation endpoint is a free cache-flush attack. Details in PERFORMANCE.md.
Data safety in the demo
localStorage holds a few megabytes and is per-browser. It is right for a demo and wrong for a shop:
- Export before you experiment. Import / export → Download JSON.
- Reset to demo data discards everything and reseeds.
- Bumping
VERSIONinsrc/lib/db.jsbackfills on next load: fields the
seed has gained since — enrichment, size charts — appear on existing products, and anything already there, including everything you have edited, is kept. Products you created are untouched.
Reseeding outright would be worse than useless: the catalogue would look fine and your work would be silently gone. A real backend runs ordered migrations against a schema; a field-level merge is the honest browser equivalent, and it is why this file never runs in api mode.
If a store looks stale after an upgrade — a block that should be there and is not — reload once. If it persists, Import / export → Reset to demo data.