Displaying Data

Introduction

Ree has three output tags plus three translation-lookup tags. Two of the output tags print values into the rendered HTML - one escapes its input, the other doesn't; the third runs plain JavaScript inline. The translation-lookup tags read strings from props.translations with a built-in safety net for missing keys - one escapes, one emits raw HTML, and one renders the value through markdown. Most templates use the escaped output tag for data and the escaped translation tag for copy, reaching for the others only when needed.

Escaped Output

{= expr } outputs an expression with HTML escaping. The characters &, <, >, ", and ' are converted to their entity equivalents before being inserted into the page, so user-supplied content cannot inject markup or break out of an attribute:

<h1>{= props.title }</h1>
<p>Welcome back, {= props.user.name }.</p>

This is the safe default. If you're not sure which output tag to use, use this one. Even values that you know come from your own database are best escaped - a column might one day hold something a user typed in.

The expression inside the tag is plain JavaScript. You can call methods, access nested properties, and use ternaries:

<p>{= props.user.name?.toUpperCase() ?? "Anonymous" }</p>
<span class="badge {= props.is_admin ? 'badge-admin' : 'badge-user' }"> {= props.tags.join(", ") } </span>

Raw HTML Output

{~ expr } outputs an expression without escaping. Use it when the value already contains trusted HTML - a markdown-rendered string, a sanitised rich-text field, a snippet you composed in your handler:

{~ props.rendered_markdown }

Never use {~ } with values that came from user input. The whole point of the escaped tag is to defend against injection; the raw tag turns that defence off. If your handler computed an HTML string from a value the user submitted, escape it before passing it in (or pre-escape parts of it and concatenate trusted markup around them).

A common safe use is rendering helper output that returns markup:

{~ yes_no(record.is_active, "both") }

The yes_no helper builds the HTML string itself and is part of Reepolee - you can trust it not to leak unescaped user input into your page.

Translation Lookups

{_ path }, {- path } and {@ path } read a translation string from props.translations - the merged translation namespace create_ctx() resolves for the current request and language. {_ } escapes its output (like {= }); {- } outputs raw HTML (like {~ }, for the rare translation value that legitimately contains markup); {@ } renders the value through markdown to HTML:

<h1>{_ ui.title }</h1>
<label for="email">{_ labels.email }</label>
<button type="submit">{_ actions.submit }</button>

path is a plain dotted key path - ui.title, labels.email, errors.required. It is not arbitrary JavaScript: no function calls, no computed keys (labels[key]), no expressions. That restriction is what lets the engine resolve the value with a safe property walk instead of evaluating it, so a missing key can never crash the render.

Missing keys render a marker, never a blank or a crash. If the key isn't present - or props.translations isn't wired up yet, which is normal while you scaffold a route before its strings exist - the tag renders the last segment wrapped in braces. A missing {_ labels.email } renders as {email}. This is the same {last_segment} convention nav_label() and the translation loader use, so a gap shows up loud on the page during development instead of silently disappearing.

Use these for every read from props.translations. {= } / {~ } can still technically reach translation data ({= props.translations.ui.title }), but that bypasses the missing-key marker - a typo renders an empty string instead of {title}. Reserve {= } / {~ } for everything that is not a translation: props.user, props.record, loop variables, computed expressions.

{_ } and {- } also work inside component attribute values - <confirm-dialog title="{_ ui.reset_btn }"> resolves against props.translations with the same miss-marker behaviour. {@ } does not: it emits block-level HTML, so it is a body-only tag.

Markdown translations - {@ path }

{@ path } resolves the translation exactly like {_ } / {- }, then renders the value through markdown to HTML (via Bun.markdown.html). Use it when a translation string is authored as markdown source - headings, lists, **bold**, links - so a content editor writes markdown instead of hand-written HTML:

<article>{@ pages.about_body }</article>

With pages.about_body set to "## Our story\n\nWe build **fast** tools.", this renders an <h2> and a paragraph with a <strong>. An empty or absent value renders nothing; a missing key renders the {last_segment} marker (which markdown wraps in a <p>).

Inline JavaScript

{{ ... }} runs arbitrary JavaScript at render time. The block produces no output of its own - its job is to compute values that you reference later in the template:

{{ const label = props.record.id ? props.labels.edit : props.labels.new; const class_map = { red: "bg-red-600
text-white", green: "bg-green-600 text-white" }; const banner_class = class_map[props.type] ?? "border
border-neutral-300"; }}

<h1>{= label }</h1>
<div class="{~ banner_class }">{~ props.text }</div>

Variables declared inside {{ ... }} are available for the rest of the template. They are scoped to the same lexical block the rest of your template runs in, so you can use const and let freely.

The most common patterns:

  • Pre-sorting or filtering a list before iterating:

    {{ const recent = props.posts.slice(0, 5) }} {#each recent as post }
    <article>{= post.title }</article>
    {/each}
    
  • Building a class string from a small lookup table:

    {{ const color = { admin: "red", staff: "blue", user: "gray" }[props.role] }}
    <span class="badge badge-{= color }">{= props.role }</span>
    
  • Pulling a frequently-used value out into a short alias:

    {{ const u = props.user }}
    <p>{= u.first_name } {= u.last_name } ({= u.email })</p>
    

Keep these blocks small. If a template starts to fill up with {{ ... }} blocks, the work usually belongs in the route handler instead - pass the precomputed value into props and reference it directly.

Scoping With {#with}

When a block repeatedly reaches into the same nested object, {#with expr}…{/with} sets a scope so bare variable names resolve against that object - like JavaScript's with statement:

{#with props.record}
<h1>{= title }</h1>
<p>{= description }</p>
{/with}

<!-- Equivalent to: -->
<h1>{= props.record.title }</h1>
<p>{= props.record.description }</p>

Two rules to keep in mind:

  • Only bare names resolve through the scope. {= title } becomes props.record.title, but {= props.x } still refers to the original top-level props - a dotted expression is never re-rooted.
  • It nests. A {#with address} inside {#with props.user} resolves against props.user.address, and {#each} works inside a {#with} block as usual.

This is heavily used in CRUD-generated templates, where handlers pass deeply nested props.columns, props.record, and props.fields and the directive keeps the markup readable.

Comments

Standard HTML comments work as you'd expect - they pass through to the rendered output:

<!-- This is visible in the page source -->

For comments you want stripped before output, wrap them in a JavaScript block:

{{ /* This is invisible to the browser */ }}

The block runs (it's just a JS comment, so it does nothing) and produces no output.

Escaping Rules in Detail

The escape function is single-pass and converts exactly five characters:

CharacterReplacement
&&amp;
<&lt;
>&gt;
"&quot;
'&#39;

null and undefined are converted to the empty string. Numbers, booleans, and dates are coerced with String(value) first, then escaped.

The same rules apply whether the value lives in element content (<p>{= x }</p>), in an attribute (<input value="{= x }">), or in a URL (<a href="{= x }">). For attributes specifically, always wrap the value in double quotes - that combined with " being escaped means the value cannot break out of the attribute.

The ...rest Shorthand

Inside a component or include, you often want to destructure the known props from props.attributes and spread the rest onto an HTML element. The ...identifier shorthand (without { }) is sugar for {~ key_values(identifier) }:

{{ const { children } = props; const { class: _class, type, text, ...rest } = props.attributes; }}
<h1 class="{= _class }" ...rest>{~ text }</h1>

The ...rest outputs each remaining attribute as key="value" pairs, properly escaped - the template equivalent of JavaScript's spread operator for HTML attributes. The key_values() helper can also be called explicitly if you need to combine several attribute spreads:

<div {~ key_values(rest) } {~ key_values(extra_attrs) }></div>

Limitations

A few things expressions cannot do:

  • No statements - if, for, while cannot appear inside {= } or {~ }. Use {{ }} for computation and {#if} / {#each} for control flow.
  • No async/await - expressions are synchronous during rendering. For async data fetching, load the data in your route handler before calling render().