Web Pages
Build websites and webshops in a blueprint. A page is rendered on the server from a template, and its buttons and forms update only the part of the page that changed: one row, one counter, the errors of one form.
A web page is a web_page block with a URL, a flow, and a web_render block that turns a template into the finished HTML. There is no frontend framework and no build step: the visitor's browser receives a complete page, with its title, description, Open Graph tags and structured data already in it. A small script (under 4 KB) makes buttons and forms work in place. When a visitor deletes a row, the server runs your blueprint and answers with "remove row p1", and only that row disappears.
How a web page works
There are two kinds of request, and each starts its own flow in the same blueprint.
| Request | Starts | Answers with |
|---|---|---|
Opening the page (GET /shop) | web_page | The whole page, from web_render. |
| Clicking a button or sending a form on that page | The web_action whose name the button carries | Only the changes: replace this row, remove that one, show these field errors. |
The action is posted to the page's own URL, so the page's scopes decide first who may use it. An action can carry scopes of its own; an action without them is allowed only for visitors every page of the blueprint allows, so a public page is never the way in to an action meant for an admin page in the same blueprint. A request from another website is refused.
Your first page
- Add a
web_pageblock. Itseventfield is the URL without the leading slash, for exampleshoporproducts/:slug; a:namesegment arrives on theparamsout-pin. Give it the scope*publicfor a page anyone may open. - Connect its flow to a
web_renderblock. - Write the template on the render block (its
templatepin, or the template stored on the block), and wire atitleanddescription. - Add a custom pin per value the template needs. A custom pin named
productsis{{products}}in the template.
<main class="shop">
<h1>{{title}}</h1>
{{if products}}
<ul class="grid">
{{for p in products}}
<li>
<a href="/products/{{p.slug}}">
<img src="{{p.image}}" alt="{{p.name}}" loading="lazy">
<strong>{{p.name}}</strong>
<span>{{p.price | money "EUR"}}</span>
</a>
</li>
{{/for}}
</ul>
{{else}}
<p>{{t "shop.empty"}}</p>
{{/if}}
</main>Without a layout, the page gets a plain HTML5 document around it. Start the template with <!doctype html> to write the whole document yourself, or name a layout (see below). The head tags are written for you in every case.
Template syntax
Web templates use Mustache, the same syntax as mail templates and page templates, with the platform's additions. A template that does not compile is an error the block reports, not an empty page.
| Write | Does |
|---|---|
{{name}}, {{user.email}} | A value, escaped for where it stands (see Escaping). |
{{{html}}} | A value written as it is. Only for HTML you built yourself, such as the output of web_foreach. |
{{if status == 'paid'}} ... {{else if status == 'open'}} ... {{else}} ... {{/if}} | Conditions with == != > < >= <=, &&, || and !. Numbers compare as numbers. |
{{for item in items}} ... {{else}} ... {{/for}} | A loop with a name for the item; {{else}} renders when the list is empty. |
{{@index}} {{@number}} {{@first}} {{@last}} {{@odd}} | The position inside a loop, counting from 0 or from 1. |
{{#items}} ... {{/items}}, {{^items}} ... {{/items}} | Plain Mustache sections, as in mail templates. |
{{> product-card}}, {{> product-card p}} | Another template by name, with the current or the given data. Only where text goes: not inside a tag, an attribute or a <script>. |
{{define "badge"}} ... {{/define}} | A small template local to this one, used with {{> badge}}. |
{{t "cart.items" count=cart.count}} | A translation in the page's language; {count} in the text is filled in. |
{{! a comment }}, \{{ | A comment, and literal braces. |
Filters
A filter changes a value before it is written: {{price | money "EUR"}}. Numbers, money and dates follow the page's language, so the same template writes € 1.234,50 on the Dutch page and €1,234.50 on the English one.
| Filter | Example |
|---|---|
money "EUR" | {{price | money "EUR"}}. For amounts in cents: {{cents | divide 100 | money "EUR"}}. |
number, number 2 | Thousands grouped, optionally fixed decimals. |
date "DD-MM-YYYY HH:mm" | Unix seconds, milliseconds or an ISO date, in the entity's time zone. Tokens: YYYY YY MM M DD D HH H mm ss. |
upper, lower, capitalize, trim | Text case and whitespace. |
truncate 80, default "-" | Shorten long text; a fallback for an empty value. |
length, join ", ", first, last | Lists. {{if items | length > 3}} works in conditions too. |
plus, minus, times, divide, round 2 | Arithmetic. |
json, urlencode, slug, nl2br, t | Encoding, a URL-safe name, line breaks as <br>, a translation of a key held in a value. |
Stored templates, layouts and partials
A template you use on more than one page belongs in the templates store, the same place mail templates live. Give it the type web and a name, and every page of the entity can use it by that name.
- A partial is used inside another template:
{{> product-card}}. - A layout wraps the page: set the render block's
layoutpin to its name. The layout prints the page with{{{body}}}and the generated head tags with{{{head}}}, and it sees the same data as the page. Its stylesheet and script are linked automatically.
<!doctype html>
<html lang="{{language}}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="{{viewport}}">
{{{head}}}
</head>
<body>
{{> site-header}}
{{{body}}}
{{> site-footer}}
</body>
</html>Saving a template takes effect on every node at once; there is nothing to deploy.
Lists and tables with web for each
web_foreach repeats a row template for every item of a list, such as the documents of a storage search, and gives every row a data-key: the document's _meta.guid, its id, or the field you name. That key is how a later action replaces or removes exactly that row. Wire its html into a custom pin of the render block and print it with triple braces.
<tr>
<td>{{name}}</td>
<td>{{price | money "EUR"}}</td>
<td>
<button type="button" data-action="edit">Edit</button>
<button type="button" data-action="delete" data-confirm="Delete {{name}}?" data-optimistic="remove">Delete</button>
</td>
</tr><table>
<tbody id="products">{{{rows}}}</tbody>
</table>Actions update only what changed
A button with data-action="delete" runs the web_action block named delete (its event field) in the page's blueprint. The action receives the form fields on form and the data-key of the row the button sits in on key. It answers with update blocks, applied in the order they ran:
| Block | Does in the browser |
|---|---|
web_replace | Swaps an element (by key or CSS target) for new HTML. The usual answer to an edit. |
web_remove | Removes an element. The usual answer to a delete. |
web_insert | Adds HTML at the end or start of an element, or before or after it. |
web_update | Gives an element new contents: a cart counter, a total. |
web_errors | Shows messages next to the form's fields. |
web_redirect | Sends the visitor to another page. |
To re-render a row after an edit, render the same row template with web_template (for a stored row template, the template is just {{> product-row}}), pass it the updated document and the key, and hand its html to web_replace.
- Delete:
web_action(delete), storage delete bykey,web_remove(key). - Edit:
web_action(save), storage update,web_templateof the row with the updated document,web_replace(key, html). - Add:
web_action(add), storage create,web_templateof the new row with its guid as key,web_insert(target "#products").
What the markup can ask for
These attributes work on any element. Everything marked "no request" happens in the browser alone.
| Attribute | Does |
|---|---|
data-action="name" | Runs the action: a button or link on click, a form on submit, an input or select on change. |
data-trigger="input" (with data-debounce="300") | Runs while typing, for live search. An earlier request still running is cancelled. |
data-trigger="load", data-trigger="every 10s" | Runs when the element appears, or repeatedly while the page is visible. |
data-key="..." | Identifies a row; sent as the action's key. |
data-param-size="xl" | An extra field size=xl for the action. |
data-confirm="Sure?" | Asks before running. |
data-loading-text="Saving" | The button's text while the action runs. Every element taking part also gets data-loading and aria-busy for your CSS. |
data-optimistic="remove" | Hides the row at once and brings it back if the action fails. |
data-toggle="#menu" | Shows or hides another element. No request. |
data-dismiss | Removes the closest row or the given element. No request. |
data-error-for="email" | Where the message for the field email goes; _form for the whole form. |
data-msg-required="..." | Your own message per failed check: required, type, pattern, minlength, maxlength, min, max. |
Forms and validation
A form with data-action checks itself before it is sent: required, type="email", pattern, minlength, min and max are judged in the browser, and the message appears next to the field without a request. A visitor fixing a field sees its message disappear while typing. Only a form that passes reaches your action.
The action checks what only the server can know (is this email already registered, is this product still in stock) and answers with web_errors: an object of field name to message, plus an optional message for the whole form. Those messages also disappear as soon as the visitor edits the field.
<form data-action="subscribe">
<label>Email <input name="email" type="email" required data-msg-required="{{t "form.email_required"}}"></label>
<div data-error-for="email"></div>
<div data-error-for="_form"></div>
<button type="submit" data-loading-text="{{t "form.sending"}}">{{t "form.subscribe"}}</button>
</form>Forms keep working without JavaScript. A plain form with a hidden _action field posts the same way; the visitor is then sent back to the page, or to the URL of a web_redirect. A web_render in an action answers with a whole page instead, for example an order confirmation.
SEO, Open Graph and languages
Every page is complete HTML when it leaves the server, so search engines and link previews see exactly what visitors see. From the render block's pins the head gets:
<title>, the meta description and a canonical link (the page URL without its query, unless you setcanonical);og:title,og:description,og:url,og:image,og:localeand the Twitter card, larger when there is animage;- a
hreflanglink for every language version of the page, from the page'sl10nURLs, with the main URL asx-default; - structured data from the
json_ldpin; - anything else from the
metapin, for example{"robots": "noindex", "og:type": "product"}.
A tag your layout already writes is not written twice. A localized URL (an l10n entry) always renders in its own language.
Why it is fast
- Templates compile once. A compiled template renders a 50-row product table in about 16 microseconds, more than twice as fast as the Mustache engine mail templates used and seven times faster than Go's own HTML templates.
- An action renders only its answer. Editing a row renders that row, not the page, and sends only that row back.
- Unchanged pages are not sent again. Every page carries an ETag; a browser that already has the page gets a 304.
- Pages can be cached. Set
cache secondson the render block and shared caches may keep a public page; a page rendered for a signed-in visitor is never cached publicly. - Links load before the click. Links a visitor hovers are prefetched. Links that run an action, and links marked
rel="nofollow"ordata-no-prefetch, never are. - Typing costs nothing. Validation, loading states, toggles and clearing errors happen in the browser.
Escaping and security
Where a value lands decides how it is escaped, and that is read from your template's own markup, so data can never escape its place.
| The value stands in | It is written as |
|---|---|
| Text, or an attribute | HTML-escaped. |
href, src, action and other URL attributes | A link that would run script (javascript: and the like) is replaced. After a ? the value is URL-encoded. |
A <script>, including JSON-LD | JSON, safe inside the script. Write var cart = {{cart}}; without quotes. |
A style attribute or <style> | Only characters a CSS value needs; anything else becomes unsafe. |
Between attributes, <input {{attrs}}> | Attribute names, or an object of attributes. Never an event handler. |
Some templates cannot be escaped safely, and those do not compile, with a message that says why: a partial inside a tag, attribute or script; an {{if}} whose branches end in different places (one inside a tag the other is not: open and close tags inside each branch); and a link whose scheme would come from data (write https://{{host}}, not {{scheme}}://{{host}}).
Every page has a Content Security Policy with a fresh nonce: inline scripts need nonce="{{nonce}}", and onclick-style attributes are blocked, which is what data-action replaces. Actions are refused when another website sends them. A visitor who reaches a broken page reads that it failed; why it failed is shown only to the entity's blueprint developers.
Letting AI build your pages
Web pages are designed to be written by an AI agent as easily as by hand: a page is a template plus a short flow, and every web block carries a precise description of what it does in the block catalog. Ask for the result you want ("a product page for every product in the products storage, with a reviews list and an add-to-cart form") and the agent can create the stored templates through the API and the blueprint with the web blocks. The agent does not call your backend from the page: the page's own request is the backend and the frontend at once.
- Keep shared pieces (header, footer, product card, row) as stored
webtemplates, so a page and its actions render them identically. - Give every element an action can change a
data-keyor anid. - Prefer one action per user intent (
add,save,delete) over one action with a switch.
Limits
- Actions take form fields and JSON. Upload files through the upload endpoints and pass the result to the action.
- HTML an action returns is inserted without running its scripts.
- An action belongs to the page's blueprint: a page cannot run another blueprint's actions directly. Call a shared function from the action instead.
- When a blueprint has pages with different scopes, give each action the scopes it needs: an action without scopes requires what every page of the blueprint requires.
Next steps
Email System Mail templates: the same template store and syntax, used for email. Storages Searching, creating and updating the documents a page shows and its actions change. Common Blueprint Patterns Flows you will combine with web pages: validation, branching, calling functions.Frequently asked
Can I build a website or webshop with RUAL blueprints?
Yes. A web_page block gives a URL a flow, and web_render renders its template on the server into a complete HTML page with title, meta description, canonical link, Open Graph and Twitter tags, hreflang links and JSON-LD. Templates use Mustache, the same syntax as mail templates, and shared pieces such as a layout, header or product card are stored templates used by name.
How do I update one row of a page without reloading it in RUAL?
Give the button data-action="name" and add a web_action block with that name to the page's blueprint. The action runs only for visitors its page (and its own scopes) allow, receives the row's data-key and answers with update blocks: web_remove removes the row, web_replace swaps it for a row rendered again with web_template, web_insert adds a new one. Only that piece of HTML is sent back.
Do RUAL web pages validate forms without a request to the server?
Yes. A form with data-action checks required, type=email, pattern, minlength and min/max in the browser and shows the message next to the field without a request. The action only checks what the server knows and answers with web_errors; those messages disappear as soon as the visitor edits the field.