Production and upgrades
A theme that renders is not a theme you can ship. This page is the list a theme has to survive before a merchant's store depends on it: accessibility, right-to-left, responsive behavior, the states nobody designs, performance, security, deployment, and the upgrade path between SDK versions.
Nothing here changes the contract; it is how the contract is used well.
Before you ship
| Area | Prove it |
|---|---|
| Contract | validateThemeDefinition(theme) is empty; the manifest round-trips; every shipped document validates in every language |
| Render | Every surface renders with no unexpected onIssue |
| Languages | Arabic renders RTL, French and English LTR, and no language shows another's copy |
| States | Empty, missing, invalid and failed data each render something sensible |
| Images | Every image has an alt (or is deliberately decorative) and a width/height or an aspect ratio |
| Money | Totals come from the server; the browser never adds a fee |
| Secrets | No store key reaches a browser bundle |
| Build | npx astro check, npm test and npm run build pass |
Accessibility
The SDK gives you the pieces; the markup is yours.
- Alt text is a field, per language. An
imagefield's value is aMediaReferencewhosealtresolves to the rendered language;pictureOf(image, config.media).altis it,""when the merchant wrote none. A decorative image has an empty alt, never a filename. - Rich text renders escaped HTML.
richTextToHtmlescapes every text node and attribute and drops unsafe links, soset:htmlis safe — but the heading levels it can emit areh2–h4. A page must own exactly oneh1, usually the section's title field. - Color contrast is checkable.
contrastRatio(a, b)gives the WCAG ratio; 4.5 is the floor for body text, 3 for large text. Warn a merchant in the editor rather than silently accepting an unreadable scheme. - Focus and keyboard. Every interactive element is a real
<a>,<button>or form control. The SDK adds no click handlers; a clickable<div>is your bug, and it does not work for a keyboard shopper. - Forms. The checkout form's fields and their validation come from the store's settings; label each input with the merchant's label text, not a placeholder alone.
- Motion. A marquee, carousel or countdown should respect
prefers-reduced-motion. The SDK has no motion primitives and adds none.
Right-to-left
Arabic is a first-class language here, not a stylesheet afterthought.
- Put
dir={isRTL ? "rtl" : "ltr"}andlang={locale}on<html>, from the render context.StoreConfig.locales[].directionis the store's own answer for each language. - The SDK's style classes are written on CSS logical properties
(
padding-inline,margin-block-start), so astylefield mirrors with no extra CSS. Write your own CSS the same way:margin-inline-start, notmargin-left. - Mirror only what carries direction: chevrons, "next" arrows, progress. Do not mirror a logo, a phone number, a clock or a product photo.
- Prices, phone numbers and tracking numbers stay LTR inside RTL text: give a
phone input
dir="ltr". The order action normalizes an Algerian number (+213…,00213…, spaces) to its local form before the server checks it. - Test with the longest Arabic string, not the shortest: Arabic copy often runs longer than its English source, and a fixed-width button clips it.
Responsive
- Breakpoints are the SDK's:
baseeverywhere,mdfrom 768 px,lgfrom 1024 px. Astylevalue is phone-first — a wider breakpoint inherits the narrower one, so never repeat abasevalue inmd. - Steps are pixels the theme chose.
s4is 16 px only because the theme mapped it; a component that hardcodes16pxbreaks the moment a merchant pickss8. - Images: pick the photo with
productImageSourceorpictureOf, and size it withmediaImageSrcsetandsizes, so a phone does not download a hero image sized for a desktop.mediaImageServicemakes Astro's<Image>do the same through the store's media delivery. - Content decides height. A section that assumes two lines of title breaks
in Arabic or with a long product name. Let text wrap; set
min-height, notheight.
Every state a section renders
| State | What the shopper should see |
|---|---|
Data is empty ([], null, {}) | An empty state, not a broken layout: no blank grid, no "undefined" |
| A read failed | The same empty state; the operator sees the cause through the client's onError |
load threw | The base props still render; the renderer reports load-failed |
| The type is unknown | Nothing renders for that instance; the rest of the page survives (unknown-type) |
| A prop is invalid | The field's default applies and invalid-prop is reported — never a crash |
| A required field has no value | missing-required-prop; the component must not print an empty heading |
| Nothing configured yet | In a preview only: a placeholder via isPreview(blocks), so a merchant can select the section |
| The document belongs to another theme | The shipped default renders; the merchant never sees an empty page |
| An editor's draft is empty | An empty page is a real answer — the merchant emptied it |
Degradation is silent to shoppers by design
A storefront must not throw because the catalogue is down. If a page 500s on a
failed read, that read was awaited without a fallback — products.list()
already returns [].
Performance
- One read cache per page.
createReadCache()shares a read across sections (and shares its rejection). One cache per section multiplies the same request by the number of sections; one per process leaks between visitors. - One client per request. The storefront client keeps small opt-in caches (config, categories, communes) per instance; sharing a client across requests shares those answers across shoppers, language included.
- Keep Zod out of the browser. Nothing in a shopper's page should import the theme SDK's validators or a schema module. Validation runs where data enters: at a route, or in the build.
- Prerender what does not depend on the request. The manifest and the styles
endpoint are build artifacts (
prerender = true). - Images carry the weight. Resize with the store's media delivery
(
config.media), sendsrcset, and give every image intrinsic dimensions so the layout does not shift. - A composed page is a budget. Fifty sections render; that does not mean they should. Every section is at least one read and one component tree.
Security
- The API key lives in a server module and a server environment variable. A
PUBLIC_*key is a published key. urlfields accept site paths, fragments, queries,http(s)://,mailto:andtel:only;javascript:,data:and protocol-relative targets are rejected at assembly and reported on read. Render them as anhref, never as raw HTML.- Rich text is a closed format: no HTML node exists to store, and
richTextToHtmlescapes what it emits. Do notset:htmla rawtextortextareafield. - An image value is a media key; the URL is derived at render. A saved
document cannot point an
<img>at another host. - The theme reads; the platform writes. There is no endpoint in a theme that saves a document, a setting or merchant text — that is Wocart Studio, and a theme that adds one has invented an authorization boundary it cannot enforce.
- Preview tokens are signed by the editor and verified with the deployment's preview key; a theme without a key answers 404 on the preview route. Never cache a preview response.
- Inline style is limited to the color-scheme sheet
ThemeStyles.astrowrites; on a page with a Content-Security-Policy it adds that sheet's hash, sostyle-srcneeds no'unsafe-inline'.
Deploying
- Build for both hosts. A Wocart theme runs on Cloudflare Workers and on
Vercel from the same code:
@astrojs/cloudflareby default and@astrojs/vercelwhenDEPLOY_TARGET=vercel, withsession: false(Quick start, step 2). Wocart Cloud builds a release twice,npm run buildandDEPLOY_TARGET=vercel npm run build, and refuses it if either fails. The Cloudflare build must writedist/server/wrangler.jsonwith a compatibility date, anddist/client/wocart-theme.json. The Vercel output in.vercel/outputmust carry no symlinks and no native binaries built for another system, which is why the image service is the passthrough or the media image service, neversharp. - Read the runtime contract. Wocart Cloud sets the same variables on
every storefront it deploys, on Cloudflare and on Vercel:
COD_SERVER_URL,STORE_API_KEYand, when preview is configured,WOCART_PREVIEW_KEY. A theme reads exactly these names and needs no other. Declare themoptionalinastro.config.mjs: the build has no store, and it loads the middleware to prerender the manifest. Hosting yourself, put them in the host's secret store.COD_SERVER_URLmust be a public origin the deployment can reach: a loopback value that works locally renders the unavailable store in production. - Build once, serve every store. A theme build is not per store: the store is decided per request by the key and the config, images included (the config carries its media delivery). Never read a store value at build.
- Know which pages are static.
wocart-theme.json,wocart-styles.cssand any hand-written page are prerendered; a surface page is rendered per request. A prerendered page cannot readAstro.locals— a build has no store. - Cache deliberately. If you cache a rendered page, tag it and drop the tag
on
REVALIDATE_PATHafter a publish; a preview response is never cached. - Verify after deploy:
/wocart-theme.jsonparses and validates, the stylesheet is served withtext/css, and a product page renders in each published language.
Versions and upgrades
Both packages follow semantic versioning: a patch fixes behavior, a minor adds
backwards-compatible features, a major may remove or change an export. An export
is marked @deprecated for at least one minor release before a major removes it.
| Fact | Value at the time of writing | Why it matters |
|---|---|---|
@wocart/theme | 4.1.0 | THEME_SDK_VERSION in every manifest |
@wocart/theme peers | @wocart/storefront ^3.0.0, astro ^7.0.0 (optional) | npm refuses an install outside the range |
@wocart/storefront | 3.1.0 | Reads platform v7; its root entry is browser-safe, /server and /astro are not |
@wocart/storefront peer | zod ^4.4.3 | Its schemas are part of its public API |
| Platform | v7 | A theme on @wocart/storefront 2.x reads a v7 store as unreadable: a store updates its platform and its theme together |
| Manifest contract | THEME_MANIFEST_CONTRACT_VERSION = 1 | An editor reads manifests of this version |
| Preview protocol | PREVIEW_PROTOCOL_VERSION = 1 | The editor and the bridge must agree |
Pin exact versions in a theme (npm install --save-exact). A theme is
rendered by its own deployment, so a floating range only means two builds of the
same commit can differ.
A theme's own versions
A theme release is breaking when a merchant's saved data stops meaning what it
meant. diffThemeManifests(previous, next) lists every such change between
two manifests:
| Breaking | Additive |
|---|---|
The theme key changes | A surface, area, component, slot, fixed block, field, option or content key is added |
A language is dropped from locales | A language is added |
| A surface, area or component is removed, or a surface renders another page type | A component becomes insertable again, or allowed on more surfaces |
| A slot or fixed block is removed, or a fixed block changes type | A section becomes required: a document that lacks it gains one |
A field is removed or changes kind; a select option or a rich-text format is removed | |
A field becomes required, or a required field is added, with no default or contentKey | |
A surface's or area's max is lowered | |
| A content key is removed in any language | |
| A style size the theme gave a step or width is removed | |
| A component stops being insertable, or is allowed on fewer surfaces |
A breaking change needs a new major version of the theme. Wocart Cloud refuses to release a theme whose manifest is breaking against the previous release under the same major. Prefer additive changes: add the new field or component and keep the old one rendering.
What a merchant saves for a theme is kept under its key: page documents,
settings and edits to the theme's words. Store data (catalog, orders, menus,
pages, languages) is shared by every theme. A store that moves to another
theme sees that theme's own defaults over the same store data, and a store that
moves back finds the first theme as it was left. Changing a theme's key
leaves every merchant's saved work for it behind.
The upgrade checklist:
- Read both packages'
CHANGELOG.mdfor every version you cross, not only the latest. - Bump the pins, then run
npx astro check,npm testandnpm run build. - Run
diffThemeManifests(previous, next)with the manifests of both releases and read every entry it calls breaking. - Run
validateThemeDefinitionagain: a newer SDK may check more than the older one did. - Re-run the preview and a shopper render, in Arabic as well as the primary.
What changed if you are coming from @wocart/theme 2.x and
@wocart/storefront 1.x:
- Images (storefront 2.0, theme 3.0): every image is a
MediaImage({ key, url, width, height }) and the config carriesmedia. The image helpers take that delivery; the media-domain presets (getCardImage,createImageHelpers,optimizeImage,generateSrcSet…) are gone, andmediaImageServicetakes a function answering the delivery. - Landing pages (theme 3.1): one
landingsurface composes every landing page; each page's own document islp:<id>. A landing surface must guarantee an order form like a product surface. Surfaces may let a page hide areas (hideableAreas). - The v7 payloads (storefront 3.0): the config no longer carries colours,
font, announcement,
themeId,cartShippingModeorlegalContact(contactreplaces it); a fallback config isunavailableStoreConfig; the store's WhatsApp is read only throughwhatsAppContact;productTags,STOREFRONT_ROUTESandresolveActionRedirectDecisionare gone. - Search and contact (theme 4.0): two new page types, so declare a surface for each; every document operation takes the theme first.
Common mistakes
| Mistake | What it costs |
|---|---|
Renaming defineTheme({ key }) | Every merchant's text edits and page documents for the theme are orphaned |
Renaming a component type | Saved instances of it stop resolving (unknown-type) |
Narrowing slots[].allow | Children that no longer fit are dropped from saved pages (disallowed-child) |
Changing a shipped document's id | A merchant's saved edit stays attached to the old id |
A field key that collides with a load key | The loaded value wins and the field silently does nothing |
A contentKey typo | Caught by validateThemeDefinition (unknown-content-key) when the theme declares content, or when you pass contentKeys; otherwise the field silently falls back to its default |
| Hardcoding a color, spacing step or font in CSS | The merchant's style / colorScheme choice has no effect |
Storing a URL in an image field | The image never resolves; a media key is what the field holds |
Reading Astro.locals.store in a prerendered page | A build error, or an empty page |
| One read cache per section | The same product is fetched once per section |
| A theme route that saves a document | The theme has no authority to write; Wocart Studio owns writes |
| Treating a theme's own choices as contract | Section names, the content layout and the design system are each theme's decisions, the starter's included; the contract is what the SDKs declare and check |