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.
Do Your First Page first if you have not: it covers the empty blueprint, the code view, saving and activating. Every flow below is Blueprint Language source for the 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. 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_cookieblock reads cookies with aui-prefix, so the block's namecartreads the cookieui-cart. An inline script needsnonce="{{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-actionruns the action. - The card has no
data-key.web_foreachadds it to the<li>, and the button's action receives it askey.
Store each one with the POST /api/v1/blueprinttemplates endpoint, with the type web. See Layouts and Partials 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(writtensearch@products) outputs the documents onarray.web_foreachrenders the card once per document. Each document's fields are the card's variables, and its_meta.guidbecomes the row'sdata-key.{{{rows}}}has triple braces because the rows are already finished HTML.cache = 60lets browsers and shared caches keep this page for a minute. The answer varies onCookie, 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 adds structured data. The data-key on <main> 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 is the search-as-you-type query. An empty box matches nothing with it, so query_branch shows the whole list again when the box is cleared. web_update keeps the <ul> 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_mutationscreates the cart the first time and updates it after that.guid_seedturns the cookie into the document's guid.mutations_addcombines the three changes into one write: one more item, the count up by one, the total up by the price.cart.objectis the document after the write, so the answer holds the true count. Only the counter changes in the browser.function_get_documentcontinues whether the document exists or not, so checkaccessible. A new visitor has no cart: the load action then answers no updates at all, and the page keeps its0.
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_emaillooks the domain up, which no browser check can do.web_errorswrites the message next to theemailfield 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. 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_redirectsends 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
l10nURLs and replace the text with{{t "shop.add"}}. See Languages. - Add structured data. Wire a Product object into
json_ldon the product page. See SEO and Sharing. - Work without JavaScript. This cart needs the script for its cookie. To set it from the server instead, send a
Set-Cookiethrough theheaderspin ofweb_render. - Keep the prices honest. Read the price again when the order is placed if your prices change often, and take stock into account.