
import { Callout } from "zudoku/ui/Callout";

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

<Callout type="tip" title="The faster way">
The [theme starter](/sdk/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.
</Callout>

<Callout type="info" title="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`.
</Callout>

## 1. Create the project

```bash
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:

```json
{
  "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"
  }
}
```

| Script | What it does |
| --- | --- |
| `build` | The Cloudflare build: the Worker in `dist/server`, the files in `dist/client` |
| `build:vercel` | The Vercel build: the Build Output in `.vercel/output` |
| `preview` | The 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

```js title="astro.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](/sdk/theme/images#astros-image-on-any-media-host)); 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`:

```jsonc title="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`:

```bash title=".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`:

```bash
.dev.vars
.wrangler/
.vercel/
```

## 3. The theme's identity

```ts title="src/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

```ts title="src/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.

| Option | Why it is there |
| --- | --- |
| `theme` | The store's text edits, settings and page documents for **this** theme arrive only when the client names it |
| `locale` | Set for a language other than the store's primary, so the server answers in that language |
| `onError` | Reads 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

```ts title="src/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

```ts title="src/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

```ts title="src/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

```ts title="src/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`:

```ts title="src/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.

```astro title="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>
```

```ts title="src/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",
    },
  ],
});
```

| Property | What it does here |
| --- | --- |
| `type` | The component's identity in saved documents. Never rename it once stores use it |
| `component` | The real `.astro` component the renderer draws |
| `label` | What 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 |
| `contentKey` | The field starts with the pack's text in the page's language |
| `required: true` | A value must exist after the pack and the default; the pack supplies it |
| `default` | The 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

```json title="src/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

```ts title="src/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.

```astro title="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

```astro title="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>
```

```astro title="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>
```

```astro title="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

```ts title="src/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

```ts title="vitest.config.ts"
/// <reference types="vitest/config" />
import { getViteConfig } from "astro/config";

export default getViteConfig({ test: {} });
```

```ts title="tests/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:

```bash
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

| Piece | Where | Its job |
| --- | --- | --- |
| The platform seam | `src/lib/store.ts` | The only module that creates a store client |
| The request store | `src/lib/context.ts`, `src/middleware.ts` | Language, redirects, the client and the text for each request |
| A definition | `src/theme/sections/hero.ts`, rendered by `src/components/sections/Hero.astro` | What a merchant may change on the hero |
| A default document | `src/theme/documents/home.json` | The home page before the store saves one |
| The catalog | `src/theme/theme.ts` | Everything editable, under one theme key |
| The renderer | `src/components/surface/Sections.astro` | Saved or default document → your components |
| The manifest | `/wocart-theme.json` | What Wocart Studio reads |
| The checks | `tests/unit/contract.test.ts` | Proof 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](/sdk/theme/starter) has
all of them; [The starter, file by file](/sdk/theme/project) shows where each
one lives, and the pages of this guide explain each piece.
