Skip to content
Free shipping over $150

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.

text
npm run dev   →   http://localhost:5173/admin

Two 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

ScreenDoes
OverviewCounts, low and out-of-stock, orders, revenue
ProductsSearch; click a row to open the full record
Product recordSix tabs: details, media, variants, fit and fabric, highlights and specs, organise. Create and delete
InventoryEvery variant, filterable to low or out, adjust by delta
CategoriesTree with parents and children, create and re-parent
Size chartsShared measurement tables
OrdersStatus, tracking, cancellation with stock return
DiscountsCodes, percentage, fixed or free shipping
StorefrontCompany profile, currency, features, recommendations, checkout, trust
Import / exportWhole-catalogue JSON, the shape the bulk endpoint takes
Developer docsThis documentation, rendered, with copy buttons

Derived on write, not stored

Three things the server computes rather than trusting a client to send:

  • `badges`sale from compare-at, sold-out and low-stock from 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

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

json
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

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

json
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

text
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

text
PATCH /admin/storefront   → the storefront document

Deep-merged, except arrays which replace wholesale — see CONFIGURATION.md.

Media

text
POST   /admin/media      multipart/form-data, field name "file"
                         → { id, url, type, width, height, duration?, bytes }
GET    /admin/media      → { items, total }
DELETE /admin/media/:id

Multipart, 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:

text
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

text
POST /admin/import
{
  "mode": "merge",
  "products": [ /* Product[] */ ],
  "categories": [ /* Category[] */ ],
  "collections": [ ],
  "settings": { }
}
→ { products: 24, categories: 20, collections: 3 }

GET /admin/export → the same shape

mode: "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

text
POST /admin/auth/login   { username, password } → { token }

The demo checks VITE_ADMIN_USER / VITE_ADMIN_PASSWORDadmin / 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:

  1. Separate the admin session from the storefront session. A customer token

must not carry admin scope. Different audience claim, ideally a different cookie domain.

  1. Authorise on the server, on every request. Hiding /admin in the client

hides nothing — the route is in the JavaScript bundle either way.

  1. Require a second factor. Admin access is full catalogue and full order

history.

  1. 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:

text
POST https://your-store.example/api/revalidate
{ "type": "product.updated", "slug": "merino-crew-knit", "at": "2026-09-09T…" }
EventPurge
product.updated product.deletedthat product, /products lists, /bootstrap
inventory.updatedthat product, /products lists
category.updated/categories, /products, /bootstrap
settings.updated/storefront, /bootstrap
order.paid order.fulfilledthat 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 VERSION in src/lib/db.js backfills 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.