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

# Quick start

This page builds the smallest complete storefront: one server-only client, a
home page that lists the store's products, and a product page that answers a
real 404. Every file is shown in full; together they type-check and build
for Cloudflare and for Vercel.

<Callout type="info" title="What you need">
Node 22.12 or newer, the store's API origin (`COD_SERVER_URL`) and a store API
key (`STORE_API_KEY`). A self-hosted Wocart runs its API locally on
`http://localhost:8787`.
</Callout>

## 1. Create the project

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

`zod` 4 is the SDK's peer dependency. A Wocart storefront runs on two hosts
from the same code, Cloudflare Workers and Vercel, so it installs both
adapters; `wrangler` runs the Cloudflare build locally. Then set the build
scripts in `package.json`:

```json
{
  "scripts": {
    "dev": "astro dev",
    "build": "astro build",
    "build:vercel": "DEPLOY_TARGET=vercel astro build",
    "preview": "astro build && wrangler dev"
  }
}
```

`build` is the Cloudflare build (`dist/`), `build:vercel` the Vercel build
(`.vercel/output`).

## 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 }),
    },
  },
});
```

Both values are **server secrets**: never `PUBLIC_*`, never rendered into a
page, never passed to a browser script. They are `optional` so a build never
needs a store; a request without them fails loudly instead (step 3).

`DEPLOY_TARGET=vercel` builds with the Vercel adapter; without it the
storefront builds for Cloudflare. `session: false` keeps the Cloudflare Worker
from requiring a `SESSION` KV namespace the storefront never uses. Images pass
through: Astro's default
`sharp` service would put native binaries for the build machine into the
Vercel output. To resize store images on either host, use the
[media image service](/sdk/theme/images#astros-image-on-any-media-host).

The Cloudflare build reads its runtime from `wrangler.jsonc`:

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

`astro dev` runs 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: add `.dev.vars`, `.wrangler/` and `.vercel/` to
`.gitignore`.

## 3. One module creates the 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";

export function createStore(locale?: string): StoreClient {
  return createStoreClient({
    serverUrl: COD_SERVER_URL ?? "",
    apiKey: STORE_API_KEY ?? "",
    ...(locale === undefined ? {} : { locale }),
    onError: (error) => {
      console.warn(`[store] degraded read: ${error.operation} (${error.code})`);
    },
  });
}
```

- **One place.** If the origin or key is wrong, this is the file to inspect.
  No page builds its own client.
- **A new client per call**, so per request: one request's reads never wait on
  another's, and a client's caches never cross requests.
- **`createStoreClient` throws `MISSING_CONFIGURATION`** when `serverUrl` or
  `apiKey` is empty — a loud setup error instead of a store that silently
  looks empty.
- **`onError`** is the only place you see why a read degraded: reads never
  throw (step 5).
- **`locale`** asks the server for a published language other than the
  primary. [Store config and languages](/sdk/storefront/config) adds language
  routing.

## 4. List the products

```astro title="src/pages/index.astro"
---
import { formatMoney, storefrontPath } from "@wocart/storefront";
import { createStore } from "../lib/store";

const store = createStore();
const [config, products] = await Promise.all([
  store.config.get(),
  store.products.list({ limit: 12 }),
]);
const money = (amount: number) =>
  formatMoney(amount, { symbol: config?.currencySymbol ?? "دج", locale: config?.locale ?? "ar" });
---
<html lang={config?.locale ?? "ar"} dir={config?.locales[0]?.direction ?? "rtl"}>
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>{config?.name ?? "Store"}</title>
  </head>
  <body>
    <h1>{config?.name}</h1>
    {products.length === 0 ? (
      <p>No products yet.</p>
    ) : (
      <ul>
        {products.map((product) => (
          <li>
            <a href={storefrontPath("product", { handle: product.handle })}>{product.name}</a>
            <span>{money(product.price)}</span>
          </li>
        ))}
      </ul>
    )}
  </body>
</html>
```

- `config.get()` and `products.list()` run in parallel: neither needs the
  other.
- **Empty is a state, not an error.** A failed read and a store with no
  products both give `[]`. Design the empty page; `onError` tells you which.
- `storefrontPath("product", { handle })` gives `/products/<handle>`, the
  platform's path for a product. Never write the string by hand.
- `formatMoney` places the store's currency symbol for its language.

## 5. Render a product

```astro title="src/pages/products/[handle].astro"
---
import { formatMoney } from "@wocart/storefront";
import { notFound } from "@wocart/storefront/astro";
import { createStore } from "../../lib/store";

const store = createStore();
const [config, product] = await Promise.all([
  store.config.get(),
  store.products.get(Astro.params.handle!),
]);
if (product === null) return notFound(Astro);

const price = formatMoney(product.price, { symbol: config?.currencySymbol ?? "دج", locale: config?.locale ?? "ar" });
---
<html lang={config?.locale ?? "ar"} dir={config?.locales[0]?.direction ?? "rtl"}>
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>{product.name}</title>
  </head>
  <body>
    <h1>{product.name}</h1>
    <p>{price}</p>
    {product.description && (product.descriptionFormat === "html"
      ? <div set:html={product.description} />
      : <p>{product.description}</p>)}
  </body>
</html>
```

```astro title="src/pages/404.astro"
---
---
<html lang="ar" dir="rtl">
  <head>
    <meta charset="utf-8" />
    <title>404</title>
  </head>
  <body>
    <h1>404</h1>
  </body>
</html>
```

- **The handle is the product's public address.** Never look a product up by
  its internal id in a URL.
- **`null` is a real 404.** `notFound(Astro)` renders your `/404` page with
  status 404 and turns caching off for that response, so a product published a
  minute later is not hidden by a cached refusal.
- **A description is either text or HTML** (`descriptionFormat`). HTML was
  sanitized by the server when the merchant saved it: render it with
  `set:html`, and never sanitize it again or print it as text. Text is always
  escaped.

## 6. Run it

```bash
npx astro check
npm run build
npm run build:vercel
npm run dev
```

Open `http://localhost:4321`: the store's name and products, each linking to
its page. `/products/nope` answers 404.

## Where next

| Next | Page |
| --- | --- |
| Serve every published language at its own URL | [Store config and languages](/sdk/storefront/config) |
| Add the order form and the same-origin API routes | [Astro actions and routes](/sdk/storefront/astro), [Checkout and OTP](/sdk/storefront/checkout) |
| Add a basket | [Basket and quotes](/sdk/storefront/basket) |
| Show delivery prices per wilaya | [Delivery and geography](/sdk/storefront/delivery) |
| Let merchants edit the design in Wocart Studio | The [theme starter](/sdk/theme/starter) |

<Callout type="caution" title="The browser never gets the key">
Browser scripts import only `@wocart/storefront` (its root entry) and
`@wocart/storefront/review-media`. Anything that needs the store goes through a
route of your own that calls the SDK on the server —
[`createStorefrontRoutes`](/sdk/storefront/astro) builds them for you.
</Callout>
