Astro actions and routes
The browser never carries the store key. Every write goes through a same-origin route in your Astro app, and that route calls the SDK on the server.
The SDK ships two adapters:
createStorefrontRoutes— the routes every storefront needs.createProxyEndpoint— a validated route for your own endpoint.
Both live on @wocart/storefront/astro, a server-only entry point.
One client per request
The route's client must be the request's own, not a module singleton:
Code
serverUrl and apiKey come from server secrets, never from PUBLIC_*
variables. A shared client would carry one visitor's language and cache into
another's request. An editable theme also passes theme: THEME_KEY
(theme quick start).
The route factory
Code
context.locals.store is the request store your middleware sets
(below). An /api/… request
has no language in its URL; the SDK gives it the language of the page that
called it, so the client already speaks that language.
Each exported handler is one line:
Code
The routes
Mount each route at its path in STOREFRONT_API_PATHS: the file under
src/pages/ is the path, with Astro's [wilayaId] for the SDK's
:wilayaId.
Path (STOREFRONT_API_PATHS) | Export | Request | Response |
|---|---|---|---|
/api/otp/send (otpSend) | routes.otpSend | POST { phone } | { data: OtpSendResult } |
/api/otp/verify (otpVerify) | routes.otpVerify | POST { phone, requestId, code } | { data: OtpVerifyResult } |
/api/cart/validate (cartValidate) | routes.cartValidate | POST basket or product + quote options | { success: true, data: CartValidation } |
/api/abandoned/upsert (abandonedUpsert) | routes.abandonedUpsert | POST AbandonedCheckoutInput | 204 |
/api/abandoned/convert (abandonedConvert) | routes.abandonedConvert | POST { sessionId, orderId, orderNumber } | 204 |
/api/communes/:wilayaId (communes) | routes.communes | GET path param | { data: Commune[] } |
/api/reviews/:productId (reviewsList) | routes.reviewsList | GET + limit/offset | { data: Review[], total } |
/api/reviews/eligibility (reviewEligibility) | routes.reviewEligibility | POST { orderNumber, phone, turnstileToken? } | { data: ReviewEligibility } |
/api/reviews/upload (reviewUpload) | routes.reviewUpload | PUT file body + grant header | 201 { data: ReviewUpload } |
/api/reviews/submit (reviewSubmit) | routes.reviewSubmit | POST ReviewInput | 201 { success: true, data } |
| — | routes.orderStatus | POST { orderNumber, phone } | { data: ShopperOrderStatus } |
orderStatus has no path in the table: a theme follows an order with its
orderStatus action. Browser code calls the same paths, never a string of
its own:
Code
The factory already handles:
- the cross-origin check on every POST/PUT;
- the shopper's
User-Agent, IP andRefererforwarding to the server; - input bounds (basket length, review limits, wilaya range);
- streamed review uploads, without buffering the file;
- the error status implied by the server's
domainError.
Do not wrap these handlers in a component or reimplement their checks.
Server-only imports
@wocart/storefront/astro and @wocart/storefront/server must never reach a
browser bundle. A client-side import leaks the store key or breaks the build
with Node-only modules. Keep them in .ts route files, actions and middleware.
A custom endpoint
For your own server call, use the same proxy so validation and error mapping stay identical:
Code
client returns a result: { success: true, data }, which the route answers
as { data }, or a client write's own failure, which it answers with the
status its domainError implies. A read never fails — it resolves to
null or [] — so wrap it as a success, the way the SDK's own communes
route does. A POST is checked for its origin by default (csrfProtection).
For a GET with path params:
Code
Useful options:
| Option | Purpose |
|---|---|
inputSchema | Validate the JSON body before the server call |
queryParams | Read named query parameters for a GET |
pathParams | Read and coerce path parameters |
readInput | Build the input from a stream or custom request |
forwardAttribution | Forward the visitor's headers |
successBody | Reshape the success envelope |
successStatus | Change the success status (201 for a created row) |
noContent | Answer 204 with no body |
Leave errorStatus unset. The default maps a validation refusal to 400, a
business refusal to 422, a rate limit to 429, a timeout to 504 and an
unexpected upstream failure to 502.
Form actions and PRG
Astro actions called from an HTML form need one middleware step. The SDK provides it so every theme stores the result the same way:
Code
runFormAction runs the action and stores its result for this response. It
does nothing for a JSON call or a plain request.
Do not redirect from middleware
Astro 7 keeps an action result only for the response being rendered. A redirect
in middleware arrives at the next page with no result: a successful order never
reaches the thank-you page, and a refused order shows no message. Redirect from
the page after it reads getActionResult().
The page that rendered the form owns the decision, with
Astro.getActionResult:
Code
thankYouHref writes /thank-you?order=…&total=…&oid=… in the shopper's
language, and the thank-you page reads it back with
readThankYou(Astro.url).
A refused order needs no code here: the page renders again from the same POST,
and the form reads Astro.getActionResult(actions.placeOrder)?.error to show
it. The theme starter has the whole
flow.
Request-bound services
The routes need the request's store: its client, in its language. The SDK
decides which store and which language a request renders — a page from its path
(/fr/products/robe), an endpoint or a server island from the page that asked
for it — and the redirects: the primary's own prefix to its one address, a
language the store stopped publishing to the primary page, and a landing page
under any prefix to its unprefixed address (a landing page exists in the
primary language only). The theme keeps its client factory, the config it
renders when the store cannot be read, and the context it builds.
createStorefrontMiddleware is the whole middleware:
Code
In order, it:
- lets a prerendered file and the revalidation route through with no store;
- keeps the store a request already has:
notFound()rewrites to/404and Astro runs the middleware again, so the 404 stays in the shopper's language; - opens the editor's preview for a verified token only, sets
locals.previewto{ claims, draft, token }, and never runs an action; - resolves any other request (
resolveStoreRequest), answers its redirect, setslocals.storetocontext(store), runs a form-called action (runFormAction), and renders the unprefixed route.
Pass the preview key as a function: a secret from astro:env/server can be
set after the module loads. astro:actions exists only inside an Astro
project, which is why the theme passes getActionContext in. A step of your
own composes with Astro's sequence(); resolveStoreRequest,
resolvePreviewStore and runFormAction stay exported for a theme that
wires them itself.
Declare what it sets, so pages and routes are typed:
Code
An editable theme builds a richer context on the same call — its text, its direction, a link helper — as the theme quick start shows.
A route that reads context.locals.store relies on the middleware having
run: without it the read fails with an undefined access, not a useful
error.
A missing resource
A route that names one resource — a product, a category, a collection, a text
page, a landing page — reads it before anything streams, and answers a missing
one with the SDK's notFound: the theme's 404 page with status 404, kept out
of the cache, so a product published a minute later shows.
Code
A rewrite to /404 by hand keeps the route's own cache rule; a redirect to the
home page answers 200 — a "soft 404" for search engines and ad reviews.
The sitemap
catalogPaths(client, config) walks the store's pages (home, the catalog, each
category, every product a page at a time, and each text page in
config.pages, the ones the merchant shows in the footer) and
sitemapXml(paths, routing, origin) writes them in every published language,
each with its language alternates:
Code
Any other page you want listed (a collection, the contact page) you add to
the paths yourself before calling sitemapXml. That choice is the theme's.
The starter lists exactly what catalogPaths returns
(src/pages/sitemap.xml.ts), and marks its checkout, search, order tracking,
thank-you, landing and not-found pages noindex.
A wiring checklist
-
The client factory is called per request, never at module top level.
-
COD_SERVER_URLandSTORE_API_KEYare server env values. -
Each route file exports the handler, not a wrapper.
-
The review upload route is
PUT, notPOST. -
communesis a GET with[wilayaId]in the path. -
The middleware is
createStorefrontMiddleware, or callsrunFormActionfor form actions and never redirects. -
getActionResultis read in the page that renders after the POST. -
A custom endpoint uses
createProxyEndpoint, not a hand-written proxy. -
A middleware wired by hand resolves the store with
resolveStoreRequest, rendersrequest.rewritewhen there is one, and keeps a store the request already has. -
Browser code takes its paths from
STOREFRONT_API_PATHS. -
A missing resource returns
notFound(Astro).