Wocart

Quick start

This page builds the smallest complete editable theme: an Astro storefront that connects to a store, renders its home page from a page document, lets a merchant edit the hero's text in Wocart Studio, and publishes its manifest. Every file is shown in full. Copy them in order and the theme builds.

The faster way

The theme starter creates a complete theme in one command: every page type, the order form and the basket, colors, preview, revalidation and tests, on both hosts. Build from scratch when you want to understand every piece, or need a structure of your own. The two SDKs are the same either way.

What you need

Node 22.12 or newer, and a store to read from: the URL of its API (COD_SERVER_URL) and a store API key (STORE_API_KEY). A self-hosted Wocart gives you both; locally the API runs on http://localhost:8787.

1. Create the project

TerminalCode
npm create astro@latest my-theme -- --template minimal --typescript strict --no-git cd my-theme npm install --save-exact @wocart/theme@4.1.0 @wocart/storefront@3.1.0 npm install @astrojs/cloudflare @astrojs/vercel zod npm install -D wrangler @astrojs/check typescript vitest

A Wocart theme runs on two hosts from the same code: Cloudflare Workers and Vercel. Wocart Cloud deploys a store's theme to either, so every theme installs both adapters and builds with each (step 2). wrangler runs the Cloudflare build locally. zod is a required peer of @wocart/storefront. Pin the two Wocart packages exactly: a theme is rendered by its own deployment, and a floating range lets two builds of the same commit differ.

Then set the scripts and one override in package.json, and run npm install again:

JSONCode
{ "scripts": { "dev": "astro dev", "build": "astro build", "build:vercel": "DEPLOY_TARGET=vercel astro build", "preview": "astro build && wrangler dev", "check": "astro check", "test": "vitest run" }, "overrides": { "@cloudflare/vite-plugin": "1.53.1" } }
ScriptWhat it does
buildThe Cloudflare build: the Worker in dist/server, the files in dist/client
build:vercelThe Vercel build: the Build Output in .vercel/output
previewThe Cloudflare build, served locally by wrangler dev; npm run preview -- --port 4322 chooses the port

The override keeps @cloudflare/vite-plugin at 1.53.1. Newer releases stop Vitest at startup ("There is already a server associated with the config") when the Cloudflare adapter loads through getViteConfig, as the tests in step 15 do. The theme starter carries the same pin.

The two packages move together. @wocart/theme 4.1.0 declares @wocart/storefront ^3.0.0 as its peer, and @wocart/storefront 3.1.0 reads the payloads of platform v7.0.0. A theme on @wocart/storefront 2.x cannot read a v7 store's config and renders every page as an unreadable store.

2. Configure Astro

Javascriptastro.config.mjs
import { defineConfig, envField, passthroughImageService } from "astro/config"; const isVercel = process.env.DEPLOY_TARGET === "vercel"; const adapter = isVercel ? (await import("@astrojs/vercel")).default() : (await import("@astrojs/cloudflare")).default({ imageService: "passthrough" }); export default defineConfig({ output: "server", adapter, session: false, image: { service: passthroughImageService() }, env: { schema: { COD_SERVER_URL: envField.string({ context: "server", access: "secret", optional: true }), STORE_API_KEY: envField.string({ context: "server", access: "secret", optional: true }), WOCART_PREVIEW_KEY: envField.string({ context: "server", access: "secret", optional: true }), }, }, });
  • output: "server": pages render per request, because the store, its language and its saved documents are decided per request.
  • One switch, two hosts. DEPLOY_TARGET=vercel builds with the Vercel adapter; without it the theme builds for Cloudflare. Each adapter is imported only by its own build. Nothing else in the theme knows its host.
  • session: false: a theme keeps no server sessions. Left on, the Cloudflare adapter makes the Worker require a SESSION KV namespace that a storefront deployment does not have.
  • Images pass through. Astro's default image service is sharp, which puts native binaries for the build machine into the Vercel output, and an output carrying them cannot be deployed. When the theme resizes store images, replace the passthrough service with the media image service (Images); it serves both hosts.
  • Every value is a server secret. None is PUBLIC_*; the API key must never reach a browser.
  • They are optional on purpose. One theme build serves every store, so the build has no store values. The build also prerenders the manifest, and that loads the middleware and the module that reads these variables. A required secret would fail every build. At request time a missing value still fails loudly: createStoreClient throws MISSING_CONFIGURATION rather than rendering an empty store.

Images need no variable: the store's config says where its images are served from (config.media).

The Cloudflare build reads its runtime from wrangler.jsonc:

wrangler.jsonc
{ "name": "my-theme", "main": "@astrojs/cloudflare/entrypoints/server", "compatibility_date": "2026-04-22", "compatibility_flags": ["nodejs_compat"], "assets": { "directory": "./dist", "binding": "ASSETS" } }

The build copies it to dist/server/wrangler.json, with the compatibility date and flags the Worker runs under. Keep the date set: a deployment reads it from there.

astro dev runs the theme in Cloudflare's local runtime, which reads its values from .dev.vars:

Terminal.dev.vars
COD_SERVER_URL=http://localhost:8787 STORE_API_KEY=your-store-api-key

Never commit it. The Astro template ignores .env but not these, so add them to .gitignore:

TerminalCode
.dev.vars .wrangler/ .vercel/

3. The theme's identity

TypeScriptsrc/theme/key.ts
export const THEME_KEY = "my-theme"; export const THEME_VERSION = "1.0.0";

The key is lowercase kebab-case, at most 64 characters, and permanent: the platform stores every merchant edit for this theme under it. It lives in its own small module so server code can import it without loading every component.

4. One place that creates the store client

TypeScriptsrc/lib/store.ts
import { createStoreClient, type StoreClient } from "@wocart/storefront/server"; import { COD_SERVER_URL, STORE_API_KEY } from "astro:env/server"; import { THEME_KEY } from "../theme/key"; export const storeServerUrl = (): string => COD_SERVER_URL ?? ""; export function createStore(locale?: string): StoreClient { return createStoreClient({ serverUrl: storeServerUrl(), apiKey: STORE_API_KEY ?? "", theme: THEME_KEY, ...(locale === undefined ? {} : { locale }), onError: (error) => { console.warn(`[my-theme] degraded read: ${error.operation} (${error.code})`); }, }); }

This is the theme's one seam with the platform, and it is server-only.

OptionWhy it is there
themeThe store's text edits, settings and page documents for this theme arrive only when the client names it
localeSet for a language other than the store's primary, so the server answers in that language
onErrorReads never throw: a failed read returns null or []. This is the only place the theme can see why

A new client per call keeps one request's reads and caches out of another's.

5. The store that cannot be read

TypeScriptsrc/lib/fallback.ts
import { unavailableStoreConfig } from "@wocart/storefront"; import { storeServerUrl } from "./store"; export const FALLBACK_CONFIG = unavailableStoreConfig({ serverUrl: storeServerUrl(), locale: "ar" });

When the store's config cannot be read (the API is down, the key is wrong), the theme renders this config instead of crashing. Never write a fallback config by hand: unavailableStoreConfig always has the config's current shape, and it decides once what an unreadable store offers.

  • Nothing to buy. The cart, the checkout form's address and delivery options, the code by SMS, Turnstile, reviews and the WhatsApp button are all off, so the page can never look like a working checkout.
  • An empty id. config.id === "" is how a page tells this config apart from a real store.
  • One language. locale (default "ar") is its only language. Its name and direction come from the runtime's Intl data, because the platform's list of languages lives on the server.
  • Images from serverUrl. Its media delivery is direct, from <serverUrl>/media.

6. The theme's own text

TypeScriptsrc/theme/content.ts
export const content = { ar: { heroTitle: "مرحبا بكم", heroSubtitle: "اكتشفوا منتجاتنا" }, fr: { heroTitle: "Bienvenue", heroSubtitle: "Découvrez nos produits" }, en: { heroTitle: "Welcome", heroSubtitle: "Discover our products" }, } satisfies Record<string, Record<string, string>>;

A content pack is one flat map of keys to text per language. A field can take its starting text from a pack key, and a merchant's edit for that key, in that language, replaces it. Every pack must have the same keys. The SDK's resolveThemeContent merges them (next step).

A pack holds the theme's words, never a fact about a store. "Delivered to all 58 wilayas", opening hours, a phone number or a return period is true of one store and false of the next. Facts come from the store's data (config.contact, its pages, its shipping rates) or from a field the merchant writes.

7. The request's store

TypeScriptsrc/lib/context.ts
import { localizeHref, servedLanguage, type LocaleRouting, type StoreConfig } from "@wocart/storefront"; import type { ResolvedStore } from "@wocart/storefront/astro"; import type { StoreClient } from "@wocart/storefront/server"; import { resolveThemeContent, type ThemeRenderContext } from "@wocart/theme"; import { content } from "../theme/content"; export interface StoreContext { config: StoreConfig; client: StoreClient; locale: string; fallbackLocales: readonly string[]; isRTL: boolean; content: Record<string, string>; routing: LocaleRouting; pathname: string; href: (path: string) => string; } export function storeContext({ config, client, routing, locale, pathname }: ResolvedStore): StoreContext { return { config, client, locale, fallbackLocales: locale === routing.primary ? [] : [routing.primary], isRTL: servedLanguage(config, locale)?.direction === "rtl", content: resolveThemeContent({ content }, locale, config.contentOverrides[locale], { defaultLocale: "ar" }), routing, pathname, href: (path) => localizeHref(path, locale, routing), }; } export function renderContext( store: StoreContext, params: Readonly<Record<string, string | undefined>> = {}, ): ThemeRenderContext { return { store: store.config, content: store.content, locale: store.locale, fallbackLocales: store.fallbackLocales, isRTL: store.isRTL, url: store.pathname, params, }; }

StoreContext is your type: everything a page needs about the request, built once from the store the SDK resolves. The rules that decide that store are the SDK's:

  • The store's primary language has no prefix (/products/robe); another published language does (/fr/products/robe). The primary's own prefix redirects permanently to the unprefixed URL; a language the store stopped publishing redirects temporarily to the primary page.
  • A landing page (/lp/…) exists in the primary language only: /fr/lp/… redirects permanently to /lp/…, its query kept, and localizeHref leaves landing links unprefixed.
  • An endpoint or server island (/api/…, /_server-islands/…) takes the language of the page that asked for it, from its same-origin Referer, and is never redirected.
  • resolveThemeContent gives your pack for the language (else the default's) with the merchant's edits for this language only, and only text edits of keys your pack has. Merging another language's edits would show French text to an Arabic shopper.
  • href("/products") gives /fr/products on a French page and /products on the primary language's. Use it for every internal link.

8. The middleware

TypeScriptsrc/middleware.ts
import { getActionContext } from "astro:actions"; import { WOCART_PREVIEW_KEY } from "astro:env/server"; import { createStorefrontMiddleware } from "@wocart/storefront/astro"; import { THEME_PREVIEW_PATH } from "@wocart/theme"; import { FALLBACK_CONFIG } from "./lib/fallback"; import { createStore } from "./lib/store"; import { storeContext } from "./lib/context"; import { THEME_KEY } from "./theme/key"; export const onRequest = createStorefrontMiddleware({ client: createStore, fallback: FALLBACK_CONFIG, preview: { path: THEME_PREVIEW_PATH, publicKey: () => WOCART_PREVIEW_KEY, theme: THEME_KEY }, context: storeContext, getActionContext, });

/fr/products/robe renders the route src/pages/products/[handle].astro: the SDK rewrites to /products/robe, so you never duplicate routes per language. A prerendered page (the manifest) has no request store, so it is skipped. A page's notFound() rewrites to /404 and the middleware keeps the store it already has, so the 404 stays in the shopper's language. The same call opens the editor's preview when WOCART_PREVIEW_KEY is set, and runs a form posted to the page before it renders. Pass the key as a function: a secret can be set after the module loads.

Tell TypeScript what the middleware puts on locals:

TypeScriptsrc/env.d.ts
/// <reference types="astro/client" /> import type { PreviewLocals } from "@wocart/storefront/astro"; import type { StoreContext } from "./lib/context"; declare global { namespace App { interface Locals { store: StoreContext; preview?: PreviewLocals; } } }

9. Your first editable component

The component is ordinary Astro. Its props come from its definition.

src/components/sections/Hero.astro
--- import type { InferProps } from "@wocart/theme"; import type { hero } from "../../theme/sections/hero"; type Props = InferProps<typeof hero>; const { title, subtitle, align } = Astro.props; --- <section class="hero" data-align={align}> <h1>{title}</h1> {subtitle && <p>{subtitle}</p>} </section>
TypeScriptsrc/theme/sections/hero.ts
import { defineComponent } from "@wocart/theme"; import Hero from "../../components/sections/Hero.astro"; export const hero = defineComponent({ type: "hero", component: Hero, label: { ar: "الواجهة", fr: "Bannière", en: "Hero" }, placement: "section", fields: [ { key: "title", kind: "text", label: { ar: "العنوان", fr: "Titre", en: "Title" }, contentKey: "heroTitle", required: true, }, { key: "subtitle", kind: "textarea", label: { ar: "النص", fr: "Texte", en: "Text" }, contentKey: "heroSubtitle", }, { key: "align", kind: "select", label: { ar: "المحاذاة", fr: "Alignement", en: "Alignment" }, options: ["start", "center"], optionLabels: { start: { ar: "البداية", fr: "Début", en: "Start" }, center: { ar: "الوسط", fr: "Centre", en: "Center" }, }, default: "center", display: "segmented", }, ], });
PropertyWhat it does here
typeThe component's identity in saved documents. Never rename it once stores use it
componentThe real .astro component the renderer draws
labelWhat Wocart Studio shows, in each of the theme's languages
placement: "section"It goes at the top level of a page, never inside another component
contentKeyThe field starts with the pack's text in the page's language
required: trueA value must exist after the pack and the default; the pack supplies it
defaultThe value until a merchant chooses another

InferProps makes align the literal union "start" | "center" and always present (it has a default), and title and subtitle optional strings.

10. The home page's default document

JSONsrc/theme/documents/home.json
{ "version": 1, "surface": "home", "sections": [{ "id": "home-hero", "type": "hero" }] }

This is what the home page renders until the store saves its own. The id is unique in the document and stable: a merchant's edit is stored against it. No props, so every field takes its pack text or default.

11. The catalog

TypeScriptsrc/theme/theme.ts
import { defineTheme, type PageData } from "@wocart/theme"; import { hero } from "./sections/hero"; import { content } from "./content"; import homeDocument from "./documents/home.json"; import { THEME_KEY, THEME_VERSION } from "./key"; export const theme = defineTheme({ key: THEME_KEY, version: THEME_VERSION, locales: ["ar", "fr", "en"], content, surfaces: [ { key: "home", label: { ar: "الرئيسية", fr: "Accueil", en: "Home" }, pageType: "home", document: homeDocument as PageData, }, ], components: { hero }, });
  • Each key in components equals its definition's type. Always.
  • locales declares the theme's languages: every translated label must cover all three, and every pack must exist.
  • content declares the packs, so the SDK checks every contentKey against them.
  • The as PageData cast is only because an imported JSON file is typed loosely; the SDK checks the document for real when the theme is validated.

12. Render a surface

One component renders any surface: it picks the document, then renders each section through the SDK's renderer.

src/components/surface/Sections.astro
--- import InstanceView from "@wocart/theme/astro/InstanceView.astro"; import { createReadCache } from "@wocart/theme/astro"; import { resolveSurfaceDocument, type ReadCache, type ThemeDefinition } from "@wocart/theme"; import { renderContext } from "../../lib/context"; interface Props { theme: ThemeDefinition; surface: string; document: unknown; params?: Readonly<Record<string, string | undefined>>; reads?: ReadCache; preview?: boolean; } const { theme, surface, document, params, preview = false } = Astro.props; const store = Astro.locals.store; const { page, source, issues } = resolveSurfaceDocument(theme, surface, document, { content: store.content, locales: [store.locale, ...store.fallbackLocales], }); if (issues.length > 0) console.warn(`[my-theme] ${surface} (${source}):`, issues); const context = renderContext(store, params); const reads = Astro.props.reads ?? createReadCache(); --- {page.sections.map((section) => ( <InstanceView instance={section} theme={theme} context={context} storefront={store.client} reads={reads} preview={preview} /> ))}

resolveSurfaceDocument renders the store's saved document when this theme can, and the theme's default when nothing was saved, the saved value is not a document, or it belongs to another theme. It also adds any required or fixed block the document lacks. createReadCache() is one cache per page render: sections that read the same resource share one request.

13. The layout and the page

src/layouts/StoreLayout.astro
--- interface Props { title?: string; } const store = Astro.locals.store; const { title = store.config.name } = Astro.props; --- <!doctype html> <html lang={store.locale} dir={store.isRTL ? "rtl" : "ltr"}> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <title>{title}</title> </head> <body> <main> <slot /> </main> </body> </html>
src/pages/index.astro
--- import StoreLayout from "../layouts/StoreLayout.astro"; import Sections from "../components/surface/Sections.astro"; import { theme } from "../theme/theme"; const saved = await Astro.locals.store.client.pageData.get("home"); --- <StoreLayout> <Sections theme={theme} surface="home" document={saved} /> </StoreLayout>
src/pages/404.astro
--- import StoreLayout from "../layouts/StoreLayout.astro"; --- <StoreLayout title="404"> <h1>404</h1> </StoreLayout>

pageData.get("home") returns the store's saved document for this theme, or null when there is none or the read failed. Both cases are handled by resolveSurfaceDocument.

14. Publish the manifest

TypeScriptsrc/pages/wocart-theme.json.ts
import { themeManifestEndpoint } from "@wocart/theme/astro"; import { theme } from "../theme/theme"; export const prerender = true; export const GET = themeManifestEndpoint(theme);

The build writes /wocart-theme.json: the JSON description of everything your theme makes editable, which Wocart Studio reads. The manifest is validated when this module loads, so an invalid theme fails the build instead of shipping.

15. Prove it

TypeScriptvitest.config.ts
/// <reference types="vitest/config" /> import { getViteConfig } from "astro/config"; export default getViteConfig({ test: {} });
TypeScripttests/unit/contract.test.ts
import { describe, expect, it } from "vitest"; import { themeManifest, validatePageData, validateThemeDefinition, validateThemeManifest } from "@wocart/theme"; import { content } from "../../src/theme/content"; import { theme } from "../../src/theme/theme"; describe("the theme contract", () => { it("assembles without issues", () => { expect(validateThemeDefinition(theme)).toEqual([]); }); it("round-trips its manifest", () => { const checked = validateThemeManifest(JSON.parse(JSON.stringify(themeManifest(theme)))); expect(checked.manifest).not.toBeNull(); expect(checked.issues).toEqual([]); }); it("ships documents that validate in every language", () => { for (const locale of theme.locales) { for (const surface of theme.surfaces) { const scope = { content: content[locale], locales: [locale] }; expect(validatePageData(theme, surface.document, scope).issues).toEqual([]); } } }); });

Run the checks every change must pass:

TerminalCode
npm run check # 0 errors npm test # every contract test passes npm run build # Cloudflare: dist/server and dist/client, with dist/client/wocart-theme.json npm run build:vercel # Vercel: .vercel/output, with .vercel/output/static/wocart-theme.json

Both builds must pass: a theme that builds for one host only cannot be deployed to the other.

Start the theme with npm run dev and open http://localhost:4321. You see the store's name in the tab and the hero in the store's primary language; /fr shows it in French when the store publishes French.

What you have

PieceWhereIts job
The platform seamsrc/lib/store.tsThe only module that creates a store client
The request storesrc/lib/context.ts, src/middleware.tsLanguage, redirects, the client and the text for each request
A definitionsrc/theme/sections/hero.ts, rendered by src/components/sections/Hero.astroWhat a merchant may change on the hero
A default documentsrc/theme/documents/home.jsonThe home page before the store saves one
The catalogsrc/theme/theme.tsEverything editable, under one theme key
The renderersrc/components/surface/Sections.astroSaved or default document → your components
The manifest/wocart-theme.jsonWhat Wocart Studio reads
The checkstests/unit/contract.test.tsProof the contract holds

From here, a complete theme adds a header every page shares, every page type with its main section, a product page that takes cash-on-delivery orders, color schemes and spacing controls, Studio preview, publish revalidation and render tests. The theme starter has all of them; The starter, file by file shows where each one lives, and the pages of this guide explain each piece.