How it works
A Wocart storefront has three layers:
- Your Astro pages own the routes, layout, copy and interaction.
@wocart/storefrontreads the store and calls its server. The root entry is browser-safe; the client and the key live on the server.cod-serverowns prices, stock, coupons, delivery, OTP, order acceptance and every other rule.
The important consequence: a page renders what the server returned. It does not calculate a price, decide that a coupon applies, or mark an order as accepted.
What a source file imports
| Your file | Import from |
|---|---|
| Page frontmatter, middleware, action, API route | @wocart/storefront/server and @wocart/storefront/astro |
| Browser script or island | @wocart/storefront |
| Review photo or voice script | @wocart/storefront/review-media |
The root entry has no API key and no client factory. That is deliberate, not a convention: the package's contract test fails if the key implementation leaks into the root artifact.
One client per request
Create the client where the request is served, pass it to the page, and let it go with the response.
Code
A client holds one store's key and one language's caches. Do not keep one as a module-global and do not share it across stores.
Page types and their paths
The platform owns these paths. A storefront renders each page it supports at the
path the platform uses, and builds links with storefrontPath.
| Page type | Path | Main reads |
|---|---|---|
| Home | / | config.get, products.list, collections.get |
| Catalog | /products | products.page |
| Category | /category/:slug | categories.list, products.list({ categoryId }) |
| Collection | /collections/:handle | collections.get, products.list({ collection }) |
| Product | /products/:handle | products.get |
| Store page | /pages/:slug | pages.get |
| Landing page | /lp/:slug | pages.landing |
| Checkout | /checkout | Browser basket, then cart.validate |
| Thank you | /thank-you | orders.tracking |
| Track order | /track-order | lookupOrder, over orders.status (order number and phone); ?order=<number> fills the number in |
| Search | /search | products.search; ?q=<words> is the query |
| Contact | /contact | config.contact and whatsAppContact |
Code
STOREFRONT_PAGE_PATHS is the list; @wocart/theme's SurfacePageType names
the same page types for a theme's surfaces. Other paths
(/about, /offers/summer) belong to your Astro project. The SDK does not
constrain them, but menus, the WhatsApp launcher's switches and Studio know
only the platform's page types.
A read degrades; it does not throw
Every read resolves to an empty value when the server cannot be reached, a response no longer matches the contract, or the resource is not public:
| Read | Empty value |
|---|---|
config.get() | null |
products.get() | null |
products.list() | [] |
products.page() / products.search() | { items: [], hasNext: false, total: 0 } |
categories.list() | [] |
collections.get() | null |
menus.get() | null |
pages.get() / pages.landing() | null |
shipping.rates() | {} |
geo.communes() | [] |
cart.validate() | a failure result |
orders.tracking() | null |
A design decision follows: an empty state is part of the page, not an exception
handler. “No products yet” and “the API is briefly down” look the same to the
reader. onError is how the operator tells them apart.
Code
A catalogue can also be empty by design. Do not put a red error banner on a new store with no products.
Null on a detail page is a real 404
For products.get, collections.get, menus.get, pages.get and
pages.landing, null means “not public or not found”. Render your 404, not a
200 page with blank fields.
A write returns a result
Writes never throw for a refusal. They answer with a discriminated result:
Code
result.domainError always exists on an SDK failure. Read its category,
code, retryable, sideEffect and fieldIssues rather than parsing an HTTP
status.
The categories are:
| Category | Meaning | Usual response |
|---|---|---|
validation | A field or shape is wrong | Mark the field and keep the form |
authentication | The store key or setup is wrong | Log it; do not retry in the browser |
business | The store refused the request | Show the server's message |
transport | Network, timeout or cancellation | Offer a retry when retryable |
system | The server failed | Keep the shopper's input and offer a retry |
unknown | An unclassified gateway failure | Keep the input; do not claim success |
Retries and caching
The SDK already applies the safe policy:
- Safe reads are retried after a transport failure or a 5xx.
- Writes are never retried. A dropped connection after an order may mean the order exists. Check before asking the shopper to submit again.
config.get(),categories.list()andgeo.communes(id)cache a successful result for the client's lifetime. Failures are never cached.- Products, pages, menus, collections, rates and page data are fetched fresh. Your route cache is what decides how often a page is rebuilt.
readTimeoutMs defaults to 10 seconds for reads. Writes have no default
timeout: a write cut off by a timer may already have happened.
The security boundary
The store key is a server secret. Browser code never sees it.
Code
This is also why the SDK's browser helpers are not a second API. formatMoney,
the basket, the OTP machine and image URL builders run locally; anything that
needs the key goes through your route.
What the server still decides
Even if your page displays it, the server re-checks:
- the price of every basket line;
- stock and sellability;
- whether a coupon applies;
- the delivery fee for the chosen destination;
- whether OTP or Turnstile is required;
- whether an order is accepted at all.
The browser may be optimistic. The order is not.