Template Syntax

Web templates are Mustache, the same syntax as mail templates and page templates, with the platform's additions: {{name}} writes a value, {{if}} and {{for}} decide and repeat, and {{price | money "EUR"}} formats. A template that does not compile is an error the block reports, not an empty page.

Values are escaped unless you ask otherwise

WriteDoes
{{name}}, {{user.email}}A value, escaped for where it stands (see Escaping and Security). A missing name prints nothing.
{{.}}, {{this}}The current item itself, for a list of plain values.
{{{html}}}, {{& html}}A value written as it is. Only for HTML you built yourself, such as the output of web_foreach.
{{items.0.name}}An item of a list by its position, counting from 0.
{{items.length}}, {{name.length}}How many items a list has, or how many characters a text has.
{{@root.title}}A name read from the outermost data, past any loop or section.
{{! a comment }}A comment. It leaves nothing in the page.
\{{Literal braces.

A name is looked up from the innermost context outward, the Mustache way, so inside a section the outer values are still visible. Numbers print without a needless .0, and a list or object prints as JSON. Changing the delimiters with {{=...=}} is not supported.

Conditions compare and combine

A condition
{{if status == 'paid'}}
  <span class="ok">Paid</span>
{{else if status == 'open' && total > 0}}
  <span class="warn">Open</span>
{{else}}
  <span>Nothing to pay</span>
{{/if}}
  • Operators: == != > < >= <=, combined with &&, || and !.
  • Literals: 'text', "text", numbers, true, false and null.
  • Filters work in a condition: {{if items | length > 3}}.
  • Closing: {{/if}}, {{end}}, or the name for a single value, as in {{if user}} ... {{/user}}. {{elseif c}} is the same as {{else if c}}.
  • A comparison section also works: {{#n == 2}} ... {{/n == 2}}, and {{^n == 2}} for its opposite.

A bare value counts as false when it is missing, null, false, 0, an empty text or an empty list. Anything else counts as true, including an empty object and the text "0".

Numbers compare as numbers, also when one side is a text that holds a number, so {{if qty > 3}} works on a form field. Two texts compare as text, so "10" > "9" is false: compare with a number when you mean a number.

A branch must end where it began Open and close a tag inside one branch. <input {{if on}}checked{{/if}}> does not compile, because the branch ends in the middle of an attribute. Write <input {{if on}}checked="checked"{{/if}}>. What refuses to compile has the rule.

Loops repeat, and know where they are

Three ways to repeat
{{for product in products}}
  <li class="{{if @odd}}odd{{/if}}">{{@number}}. {{product.name}}</li>
{{else}}
  <li>Nothing here yet.</li>
{{/for}}

{{#tags}}<span>{{.}}</span>{{/tags}}
{{^tags}}<span>No tags</span>{{/tags}}

{{for tags}}<span>{{.}}</span>{{/tags}}
WriteDoes
{{for item in items}} ... {{else}} ... {{/for}}A loop with a name for the item. {{else}} renders when the list is empty. Also closes with {{end}}.
{{for v in object}} ... {{/for}}The values of an object, in the order of their keys. {{@key}} is the key.
{{#items}} ... {{/items}}A Mustache section: a list repeats and each item becomes the context, an object becomes the context, a true value shows the contents. It may hold {{else}}.
{{^items}} ... {{/items}}An inverted section: shows when the value counts as false.
{{for items}} ... {{/items}}The section form, as the platform's older templates write it. Closes with {{/for}} too.

Inside a named loop, read the item by its name. {{.}} there is the context around the loop, not the item.

Inside a loopIs
{{@index}}The position, counting from 0.
{{@number}}The position, counting from 1.
{{@first}}, {{@last}}True on the first or the last item.
{{@odd}}, {{@even}}True on odd or even positions, counted from {{@index}}: the first item is even.
{{@length}}How many items there are.
{{@key}}The key, when looping over an object.

Partials print another template

WriteDoes
{{> product-card}}Another template by name, with the current data.
{{> product-card p}}The same, with p as its context.
{{define "badge"}} ... {{/define}}A small template local to this one, used with {{> badge}}.

A partial may stand only where text goes: not inside a tag, an attribute or a <script>. Layouts and Partials covers storing them.

Translations come from your translation keys

WriteDoes
{{t "cart.items" count=cart.count}}A translation in the page's language; {count} in the text is filled in and escaped.
{{t key}}, {{key | t}}The same, with the key read from a value.
{{translate}}cart.title{{/translate}}The platform's older form.

Languages explains the fallback order and where the keys live.

Filters change a value before it is written

A filter follows a pipe: {{price | money "EUR"}}. They chain from left to right, and an argument may be a value too: {{price | times qty | money "EUR"}}. Naming a filter that does not exist is an error when the template compiles, so a typo never reaches a visitor as an empty value.

Numbers, money and dates

Numbers and money 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.

FilterExampleWrites
money "EUR"{{1234.5 | money "EUR"}}€1,234.50 in English, € 1.234,50 in Dutch, 1.234,50 € in German, 1 234,50 € in French. Without a code it is EUR. JPY, KRW, ISK and HUF have no decimals, and a currency without a symbol prints its code.
number{{1234.5 | number}}1,234.5: thousands grouped, up to 6 decimals as the number has them.
number 2{{1234.5 | number 2}}1,234.50: a fixed number of decimals, 0 to 10.
date{{created | date}}2026-03-04. Reads Unix seconds, milliseconds or an ISO date, and writes in the entity's time zone.
date "DD-MM-YYYY HH:mm"{{created | date "D/M/YY"}}4/3/26. Tokens: YYYY YY MM M DD D HH H mm ss.
plus, minus{{qty | plus 1}}Arithmetic. A value that is not a number, or a division by 0, gives an empty value.
times, divide{{cents | divide 100 | money "EUR"}}For amounts held in cents, as money takes whole units.
round, round 2{{1234.567 | round 2}}1234.57. Without an argument it rounds to a whole number.

Text

FilterExampleWrites
upper, lower{{name | upper}}ADA LOVELACE
capitalize{{name | capitalize}}Ada lovelace: the first letter only.
trim{{name | trim}}Whitespace taken off both ends.
truncate 80{{text | truncate 9}}The quick…. A second argument replaces the …: {{text | truncate 9 "..."}}.
default "-"{{nickname | default "-"}}The fallback when the value counts as false. That includes 0: {{count | default "-"}} writes - for 0.
nl2br{{description | nl2br}}The text escaped, with line breaks as <br>. No triple braces needed.
slug{{title | slug}}héllo-wörld for Héllo Wörld!: lower case, with dashes between words.
urlencode{{name | urlencode}}ada+lovelace. A value after a ? in a link is already encoded for you, so adding this there encodes it twice.
t{{status_key | t}}The translation of a key held in a value.

Lists and data

FilterExampleWrites
length{{items | length}}How many items a list or object has, or the characters in a text. Also in conditions: {{if items | length > 3}}.
join ", "{{tags | join " / "}}a / b / c. Without an argument the separator is a comma and a space.
first, last{{tags | first}}The first or the last item.
json{{cart | json}}The value as JSON. A value in a <script> is written as JSON anyway, so you need this only to show JSON as text.

The variables a template sees

The fields of the data pin come first, then every custom pin by its name, which wins over a field of the same name. On top of that, web_render adds the document's own values for the page template and its layout.

NameIsAvailable in
Your custom pins and data fieldsWhatever you wire in.Every template
web.languageThe language this render runs in.Every template
web.nonceThe response's nonce, for inline scripts.Every template
web.urlThe page's URL without its query.Every template
web.path, web.originThe path alone, and the scheme and host alone.Every template
web.actionThe name of the running action, empty on a page request.Every template
web.signed_inWhether the visitor is signed in.Every template
title, descriptionThe render block's pins, unless data or a custom pin already has that name.Page and layout
nonce, languageThe same as web.nonce and web.language.Page and layout
viewportwidth=device-width, initial-scale=1Page and layout
head, tagsWhere the generated head tags go. Print with {{{head}}}.Page and layout
bodyThe rendered page. Print with {{{body}}}.Layout

A web_template or a web_foreach sees your values and web only, so write {{web.nonce}} and {{web.language}} there. Partials see whatever the template around them sees.

Whitespace and limits

  • A block tag standing alone on a line leaves no blank line behind, as in Mustache.
  • One render may write at most 16 MB. Partials may nest 48 levels deep, so a partial that includes itself fails with a message and does not hang.
  • An error names its line and column, for example line 2 col 1: this block is never closed.

Next steps

Layouts and Partials Store a template once and use it on every page. Escaping and Security How a value is written in each place, and what refuses to compile. Lists and Tables When to use a web for each block and when a template loop.

Frequently asked

Which template syntax do RUAL web pages use?

Mustache, the same syntax as mail templates, plus named loops with for and else, conditions that compare values with if, partials, translations with t, and filters. A template that does not compile is an error the block reports, not an empty page.

How do I format a price or date in a RUAL web template?

With a filter: {{price | money "EUR"}} or {{created | date "DD-MM-YYYY"}}. Numbers and money follow the page language, so the same template writes the amount the Dutch way on the Dutch page and the English way on the English one. A date writes the digits you ask for, in the entity's time zone.