---
title: "Tutorial: A Small Webshop · RUAL Documentation"
description: "A product list from a storage, a product page with a clean URL, a live search, an add-to-cart action and a checkout form, built step by step."
canonical: https://docs.rual.nl/web/build-a-shop
language: en
---

# Tutorial: A Small Webshop

Build a working shop in one blueprint: a product list from a storage, a product page at a clean URL, a live search, an add-to-cart button that updates a counter, and a checkout form that shows the server's answer next to the right field.

What you build

- Four pages: `/shop`, `/shop/:slug`, `/checkout` and `/thanks`.

- Three actions: search as you type, add to cart, place the order.

- One shared layout and one shared product card, stored once and used by the page and its actions alike.

Do [Your First Page](https://docs.rual.nl/web/first-page) first if you have not: it covers the empty blueprint, the code view, saving and activating. Every flow below is [Blueprint Language](https://docs.rual.nl/engine/blueprint-language) source for the [Studio Code View](https://docs.rual.nl/engine/studio-code-view), which is where a page's `event` and `scopes` are set today. In this source, `pin: value` wires a block into a pin, and `pin = value` stores the value on the pin itself.

## Start with three storages and a few products

Create these in **Manage Storages** in Studio. A storage holds JSON documents and needs no schema up front; see [Storages](https://docs.rual.nl/blueprints/storage). **Check** in the code view warns about a field name that is not listed in **Manage Fields**: add the fields there to keep things tidy, or read past the warning, as the flows run either way.

| Storage | A document holds |
| --- | --- |
| `products` | `name`, `slug`, `price`, `description` and optionally `image` (a `/path` or an https URL) |
| `carts` | `items` (a list of name and price), `count`, `total` |
| `orders` | `name`, `email`, `address`, `items`, `total` |

Fill `products` with a throwaway flow in a blueprint of its own. It has no scopes, so it is closed to everyone but developers. Save it, activate it, open `/shop-seed` once, then delete the blueprint. Keep it out of the shop's blueprint: an action without scopes must be allowed by every page of its blueprint, and this page would close them all to the public.

```
blueprint "Shop seed"

flow seed = web_page with {event: "shop-seed"} {
  create@products(mutations{name: "Green tea", slug: "green-tea", number price: 4.5, description: "Light and grassy."})
  create@products(mutations{name: "Black tea", slug: "black-tea", number price: 3.95, description: "Strong enough for breakfast."})
  create@products(mutations{name: "Teapot", slug: "teapot", number price: 24, description: "Holds four cups."})
  web_redirect(url = "/shop")
}
```

## Store the shared pieces once

A page and its actions must render a product the same way, so the card is a stored template and not a copy. The same goes for the document around every page.

```
<!doctype html>
<html lang="{{language}}">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="{{viewport}}">
  {{{head}}}
  <script nonce="{{nonce}}">
    if (!document.cookie.includes('ui-cart=')) {
      document.cookie = 'ui-cart=' + crypto.randomUUID() + '; path=/; max-age=2592000; samesite=lax';
    }
  </script>
</head>
<body>
  <header>
    <a href="/shop">Shop</a>
    <a href="/checkout">Cart</a>
    <span id="cart-count" data-action="cart-count" data-trigger="load">0</span>
  </header>
  {{{body}}}
</body>
</html>
```

```
<li>
  {{if image}}<img src="{{image}}" alt="{{name}}" loading="lazy">{{/if}}
  <a href="/shop/{{slug}}"><strong>{{name}}</strong></a>
  <span>{{price | money "EUR"}}</span>
  <button type="button" data-action="add-to-cart" data-loading-text="Adding">Add to cart</button>
</li>
```

- **The small script** gives a new visitor a random cart id in a cookie. The [`httpconnection_get_cookie`](https://docs.rual.nl/block-types/http%20connection/httpconnection_get_cookie) block reads cookies with a `ui-` prefix, so the block's name `cart` reads the cookie `ui-cart`. An inline script needs `nonce="{{nonce}}"` or the browser refuses it.

- **The counter** fills itself with an action when the page loads. That keeps the page itself the same for everyone, which is what lets you cache it below. It stands next to the link and not inside it, because a click on anything with `data-action` runs the action.

- **The card** has no `data-key`. [`web_foreach`](https://docs.rual.nl/block-types/web/web_foreach) adds it to the ` `, and the button's action receives it as `key`.

Store each one with the `POST /api/v1/blueprinttemplates` endpoint, with the type `web`. See [Layouts and Partials](https://docs.rual.nl/web/layouts-and-partials#store-a-template) for the request and for how to do this from **Manage Templates**.

```
curl -X POST https://your-cluster/api/v1/blueprinttemplates \
  -H "Authorization: Bearer $RUAL_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"type": "web", "name": "product-card", "html": "<li>...</li>"}'
```

## The product list is a search, a web_foreach and a render

Create a new blueprint named `Shop`, close the type dialog and switch to `Code`. All the remaining flows go in this one file.

```
blueprint "Shop"

flow shop = web_page with {event: "shop", scopes: ["*public"]} {
  found = search@products(query: query_sort_field[name](asc: true), limit: 48)
  web_render(shop.connection,
    rows: web_foreach(items: found.array, template = "{{> product-card}}", empty = "<li>Nothing here yet.</li>"),
    title = "Shop",
    description = "Tea and everything to make it with.",
    layout = "shop-layout",
    cache = 60,
    template = "{{> shop-page}}"
  ) with {custom_pins: [{id: "rows", state: "rows", type: "value"}]}
}
```

```
<main>
  <h1>{{title}}</h1>
  <input type="search" name="q" placeholder="Search products" aria-label="Search products"
         data-action="search" data-trigger="input" data-debounce="250">
  <ul id="products">{{{rows}}}</ul>
</main>
```

- [`function_search`](https://docs.rual.nl/block-types/storage/function_search) (written `search@products`) outputs the documents on `array`.

- [`web_foreach`](https://docs.rual.nl/block-types/web/web_foreach) renders the card once per document. Each document's fields are the card's variables, and its `_meta.guid` becomes the row's `data-key`.

- `{{{rows}}}` has triple braces because the rows are already finished HTML.

- `cache = 60` lets browsers and shared caches keep this page for a minute. The answer varies on `Cookie`, so a visitor with a cart cookie gets a copy of their own, and a visitor without one shares a copy with others. No copy holds anyone's cart, as the count arrives through the load action, and a signed-in visitor never gets a public copy.

Check, stage and save, click `Inactive` in the top bar to activate the blueprint, and open `/shop`. Your three products are there. From here on, each new flow goes live when you save it.

## The product page reads its slug from the URL

The `:slug` in the URL arrives on the page's `params` out-pin. A product that does not exist answers a real 404, with its own small template.

```
flow product_page = web_page with {event: "shop/:slug", scopes: ["*public"]} {
  let {slug} = product_page.params
  found = function_search_single_result@products(query: query_bool_filter(term{slug: slug}))
  guard found.has_hits else {
    web_render(product_page.connection,
      status = 404,
      title = "Not found",
      layout = "shop-layout",
      template = "<main><h1>We do not sell that</h1><p><a href='/shop'>Back to the shop</a></p></main>"
    )
  }
  web_render(product_page.connection,
    product: found.object,
    title: found.object.name,
    description: found.object.description,
    image: found.object.image,
    layout = "shop-layout",
    template = "{{> product-page}}"
  ) with {custom_pins: [{id: "product", state: "product", type: "object"}]}
}
```

```
<main data-key="{{product._meta.guid}}">
  {{if product.image}}<img src="{{product.image}}" alt="{{product.name}}">{{/if}}
  <h1>{{product.name}}</h1>
  <p>{{product.price | money "EUR"}}</p>
  <p>{{product.description | nl2br}}</p>
  <button type="button" data-action="add-to-cart" data-loading-text="Adding">Add to cart</button>
</main>
```

The `title`, `description` and `image` pins also become the page's Open Graph tags, so a shared link shows the product. [SEO and Sharing](https://docs.rual.nl/web/seo) adds structured data. The `data-key` on ` ` gives the button a `key` here too, so the same action works on both pages.

## Live search answers with new rows only

The search box in `shop-page` has `data-trigger="input"`, so it runs the action while the visitor types, 250 milliseconds after the last key. An earlier request still running is cancelled. The action renders the same card and replaces the contents of the list.

```
flow find = web_action with {event: "search"} {
  let {q} = find.form
  found = search@products(
    query: query_branch(
      then: query_bool_must(query_bool_match_phrase_prefix[name](value: q)),
      else: query_sort_field[name](asc: true),
      condition: condition_not_empty_value(q)
    ),
    limit: 48
  )
  web_update(
    target = "#products",
    html: web_foreach(items: found.array, template = "{{> product-card}}", empty = "<li>Nothing found.</li>")
  )
}
```

[`query_bool_match_phrase_prefix`](https://docs.rual.nl/block-types/query/query_bool_match_phrase_prefix) is the search-as-you-type query. An empty box matches nothing with it, so [`query_branch`](https://docs.rual.nl/block-types/query/query_branch) shows the whole list again when the box is cleared. [`web_update`](https://docs.rual.nl/block-types/web/web_update) keeps the ` ` and gives it new contents.

## Add to cart updates one counter

The button sits in a row with a `data-key`, so the action knows which product was clicked without any extra field.

```
flow add = web_action with {event: "add-to-cart"} {
  product = get@products(guid: add.key)
  guard product.accessible else {
    log("Add to cart for a product that is gone")
  }
  cart = function_upsert_document_from_mutations@carts(
    guid_seed: httpconnection_get_cookie(add.connection, name = "cart"),
    mutations: mutations_add(
      mutations_1: mutations_add_to_custom_array_non_unique(
        field = "items",
        object: {name: product.object.name, number price: product.object.price}
      ),
      mutations_2: mutations_increment_by_field[number count](1),
      mutations_3: mutations_increment_by_field[number total](product.object.price)
    )
  )
  web_update(target = "#cart-count", html: cart.object.count)
}

flow count_items = web_action with {event: "cart-count"} {
  cart = get@carts(guid: guid(seed: httpconnection_get_cookie(count_items.connection, name = "cart")))
  guard cart.accessible else {
    log("A new visitor has no cart yet")
  }
  web_update(target = "#cart-count", html: cart.object.count)
}
```

- [`function_upsert_document_from_mutations`](https://docs.rual.nl/block-types/storage/function_upsert_document_from_mutations) creates the cart the first time and updates it after that. `guid_seed` turns the cookie into the document's guid.

- [`mutations_add`](https://docs.rual.nl/block-types/mutations/mutations_add) combines the three changes into one write: one more item, the count up by one, the total up by the price.

- `cart.object` is the document after the write, so the answer holds the true count. Only the counter changes in the browser.

- [`function_get_document`](https://docs.rual.nl/block-types/storage/function_get_document) continues whether the document exists or not, so check `accessible`. A new visitor has no cart: the load action then answers no updates at all, and the page keeps its `0`.

## Checkout checks in the browser, then on the server

The page lists the cart and shows the form. A visitor without a cart has no `cart.count`, and the template's `{{else}}` handles that.

```
flow checkout = web_page with {event: "checkout", scopes: ["*public"]} {
  cart = get@carts(guid: guid(seed: httpconnection_get_cookie(checkout.connection, name = "cart")))
  web_render(checkout.connection,
    cart: cart.object,
    title = "Checkout",
    description = "Your cart and your details.",
    layout = "shop-layout",
    template = "{{> checkout-page}}"
  ) with {custom_pins: [{id: "cart", state: "cart", type: "object"}]}
}
```

```
<main>
  <h1>{{title}}</h1>
  {{if cart.count}}
    <ul>
      {{for item in cart.items}}
        <li>{{item.name}}: {{item.price | money "EUR"}}</li>
      {{/for}}
    </ul>
    <p><strong>Total: {{cart.total | money "EUR"}}</strong></p>

    <form data-action="place-order">
      <label>Name
        <input name="name" required data-msg-required="Please fill in your name.">
      </label>
      <label>Email
        <input name="email" type="email" required
               data-msg-required="We need your email for the receipt."
               data-msg-type="That does not look like an email address.">
      </label>
      <label>Address
        <textarea name="address" required data-msg-required="Where should we send it?"></textarea>
      </label>
      <button type="submit" data-loading-text="Placing your order">Place order</button>
    </form>
  {{else}}
    <p>Your cart is empty. <a href="/shop">Back to the shop</a>.</p>
  {{/if}}
</main>
```

The browser runs the `required` and `type="email"` checks before anything is sent, and shows your `data-msg-*` text next to the field. Only a form that passes reaches the action, which checks what only the server can know:

```
flow order = web_action with {event: "place-order"} {
  let {name, email, address} = order.form
  let check = condition_validate_email(email)
  guard check.valid else {
    web_errors(errors: {email: "This address cannot receive mail."})
  }
  let cart_guid = guid(seed: httpconnection_get_cookie(order.connection, name = "cart"))
  cart = get@carts(guid: cart_guid)
  guard cart.accessible else {
    web_redirect(url = "/checkout")
  }
  create@orders(mutations{
    name: name,
    email: email,
    address: address,
    array/object items: cart.object.items,
    number total: cart.object.total
  })
  function_delete_document@carts(guid: cart_guid, very_sure: true)
  web_redirect(url = "/thanks")
}

flow thanks = web_page with {event: "thanks", scopes: ["*public"]} {
  web_render(thanks.connection,
    title = "Thank you",
    description = "Your order is on its way.",
    layout = "shop-layout",
    template = "<main><h1>{{title}}</h1><p>{{description}}</p></main>"
  )
}
```

- [`condition_validate_email`](https://docs.rual.nl/block-types/condition/condition_validate_email) looks the domain up, which no browser check can do. [`web_errors`](https://docs.rual.nl/block-types/web/web_errors) writes the message next to the `email` field and marks it invalid, and the message goes away when the visitor edits the field.

- The cart is deleted for good with [`function_delete_document`](https://docs.rual.nl/block-types/storage/function_delete_document). A soft remove would come back with its old items at the next upsert on the same guid, because the visitor keeps the same cookie.

- [`web_redirect`](https://docs.rual.nl/block-types/web/web_redirect) sends the browser to the thank-you page.

## Try the whole shop

- Save

Stage the source, review it on the canvas and save. The blueprint is already active, and saving is what publishes.

- Browse

Open `/shop`, type in the search box and watch the list change. In the network panel of your browser each search is one short request.

- Add to the cart

Press **Add to cart** on a card, then open a product and add it from there. The counter goes up each time and the page does not reload.

- Check out

Open `/checkout`, leave the email empty and submit: the message appears with no request. Use an address on a domain that does not exist: the server's message appears in the same place. Fix it and you land on `/thanks`.

## Where to take it next

- **Translate it.** Give the pages `l10n` URLs and replace the text with `{{t "shop.add"}}`. See [Languages](https://docs.rual.nl/web/languages).

- **Add structured data.** Wire a Product object into `json_ld` on the product page. See [SEO and Sharing](https://docs.rual.nl/web/seo#json-ld).

- **Work without JavaScript.** This cart needs the script for its cookie. To set it from the server instead, send a `Set-Cookie` through the `headers` pin of [`web_render`](https://docs.rual.nl/block-types/web/web_render).

- **Keep the prices honest.** Read the price again when the order is placed if your prices change often, and take stock into account.

## Next steps

- [Actions](https://docs.rual.nl/web/actions): Every update block, with its pins and rules.

- [Forms and Validation](https://docs.rual.nl/web/forms): All the browser checks, and forms that work without JavaScript.

- [Layouts and Partials](https://docs.rual.nl/web/layouts-and-partials): How stored templates are found, and how saving one takes effect.
