Docs

HTML rendering

Render typed Rust values to HTML with render traits and the html! macros.

Open Markdown

Proa lets you use ordinary Rust for dynamic values, components, conditions, matches, and loops. You write templates with html! or html_sync!, then implement a render trait to turn the template into a component.

Everything on this page renders on the server. That is not a mode you select per route or per component: a component writes bytes into the response, the reader gets a complete page, and nothing on it needs JavaScript to appear. There is no hydration pass by default, and a page ships no client JavaScript at all until you add an island, which is opt-in per component. A wholly client-rendered SPA is mounted separately with ExternalApp::csr_app; it is not a Proa component render mode.

Writing templates

html_sync! and html! share the same template syntax. The examples in this section use html_sync! because most components do not need to await anything.

DOM Elements

Standard elements, attributes, dynamic values, and components share one tree:

src/components/hero.rs
html_sync! {
    <section data-component="hero" aria-labelledby="hero-title">
        <h1 id="hero-title">"Hello World!"</h1>
        <p>"Count: "{self.count}</p>
        {Price { amount: self.price }}
    </section>
}

Write attribute names with their HTML spelling: aria-label, aria-labelledby, and data-component stay kebab-case in the template and rendered DOM.

Text nodes are string literals in double quotes, and Proa escapes them automatically. Characters like <, >, &, and non-breaking spaces are encoded per the HTML serialization algorithm.

Rust expressions go in curly braces. Values render as text, while components use rust struct syntax and render in place.

Formatted text with text!

Use a plain {value} slot for one dynamic value. Use text! when a text node mixes static text, multiple values, or Rust formatting directives:

src/components/block_gallery.rs
html_sync! {
    <article class="block-gallery-card">
        <iframe
            src={block.preview_href}
            title={text!("{} block preview", block.name)}
            loading="lazy"
        ></iframe>
        <p>
            {text!("Explore the {} block.", block.name)}
        </p>
    </article>
}

text! escapes for its position: attribute escaping in title, and text-node escaping in the caption. Bare {} placeholders use Proa rendering; format specifiers such as {:.2} use Rust formatting. Write {{ or }} for literal braces.

Unlike format!(), text! writes directly to Proa's output buffer. This avoids allocating an intermediate String and then copying it into the response on every render, which reduces work in a hot render path.

The Proa CLI is designed to catch this mistake. If you use format!() while rendering, proa lint reports the allocation and suggests a streaming alternative:

$ proa lint src/components/greeting.rs
src/components/greeting.rs:4:17: warning[perf/format]: format!() in a Proa render path allocates a String on every render
  help: for text, render adjacent pieces like `"$"{price}" / night"`; for attrs, precompute or use `text!(...)`

Placeholder-bearing text! is rejected in URL attributes such as href and src; use a validated URL value or typed URL helper instead.

Control flow (if, for, match, etc.) is Rust

Inside an ordinary server-rendered component, a { ... } slot accepts Rust expressions. Use if, match, and for directly in the render tree. When a branch or loop iteration produces markup, return another template fragment:

src/components/product_list.rs
html_sync! {
    <section class={if self.featured { "featured-products" } else { "products" }}>
        {if self.products.is_empty() {
            html_sync! {
                <p>"No products found"</p>
            }
        } else {
            html_sync! {
                <ul>
                    {for product in self.products {
                        html_sync! {
                            <li>
                                {match &product.status {
                                    Status::Active => html_sync! {
                                        <span class="text-green-600">"Active"</span>
                                    },
                                    Status::Inactive => html_sync! {
                                        <span class="text-red-600">"Inactive"</span>
                                    },
                                }}
                                {ProductCard { product }}
                            </li>
                        }
                    }}
                </ul>
            }
        }}
    </section>
}

The class conditional returns strings directly; branches that produce elements return template fragments.

Option<T>

Option<T> renders its value when it is Some and nothing when it is None, so optional content often needs no explicit branch:

src/components/panel.rs
html_sync! {
    <section>
        <h2>{self.title}</h2>
        {self.subtitle}
    </section>
}

RSJS note: These examples show ordinary server-rendered components. Expressions that must also compile into browser JavaScript inside an RSJS-analyzed component use a deliberately portable subset of Rust. See Islands: when and why before moving behavior to the browser.

Attributes

Static values are string literals; dynamic values go in braces and are escaped before rendering:

src/components/link.rs
html_sync! {
    <a href={self.href} aria-label={self.label}>
        {self.children}
    </a>
}

Option<&str> omits the attribute entirely when None:

src/components/tab.rs
let selected: Option<&str> = self.selected.then_some("true");

html_sync! {
    <button aria-selected={selected}>"Tab"</button>
}

Valueless attributes render as the bare attribute name (disabled, open), which is spec-compliant:

src/components/form.rs
html_sync! {
    <input type="text" disabled />
    <details open>
        <summary>"Click"</summary>
    </details>
}

The macro accepts any attribute name, so htmx, Alpine, ARIA, and data-* attributes work without a fixed allowlist. Prefer their canonical hyphenated HTML spelling; underscores are accepted and normalized to dashes when needed:

src/components/save.rs
html_sync! {
    <button hx-post="/api/submit" hx-swap="outerHTML">"Save"</button>
    <div data-user-id="123" data-role="admin">"content"</div>
}

Prefer data attributes for JavaScript hooks and tests, and classes for styling. Never parse a generated class string as state.

Documents and comments

<!DOCTYPE html> works inside the macro, and <>...</> renders siblings without a wrapper.

Rust comments inside html! are stripped by the compiler and never reach the output. /// doc comments do not work there, they are only valid before item definitions.

There is no <!-- --> syntax. HTML comments in SSR output are almost always wasted bytes, so including one is deliberate:

src/layouts/root.rs
use proa_core::StaticRaw;

html_sync! {
    {StaticRaw("<!-- page boundary -->")}
    <div>"content"</div>
}

Choosing a render trait

OutputSynchronousAsync-capable
HTMLWebRenderSync + html_sync!WebRender + html!
MarkdownMdRenderSync + md_sync!MdRender + md!

Every component picks one of these two, and the choice is narrow: use the synchronous trait unless the component itself has to .await while rendering. Both render on the server; the difference is whether rendering can suspend.

A synchronous component writes its bytes and returns. There is no future, no driver, and no scheduling. An async-capable component returns a RenderOutcome instead, which a driver at the route or adapter boundary resolves. Most route bodies, layouts, and UI components stay synchronous.

WebRenderSync with html_sync!. This is the shape of almost every component you will write.

src/components/price.rs
use proa_core::{DataLoader, WebContext, WebRenderSync, WriteError};
use proa_macros::html_sync;

pub struct Price {
    pub amount: u32,
}

impl<L: DataLoader> WebRenderSync<L> for Price {
    fn render(self, cx: &mut WebContext<L>) -> Result<(), WriteError> {
        html_sync! {
            <span data-component="price" class="font-semibold">"$"{self.amount}</span>
        }
        .render(cx)
    }
}

WebRender with html!. The signature is heavier because the returned future borrows the context, so reach for it only when you need to .await.

src/components/async_price.rs
use std::future::Future;

use proa_core::{
    DataLoader, RenderOutcome, WebContext, WebRender, WriteError,
};
use proa_macros::html;

pub struct AsyncPrice;

impl<L: DataLoader> WebRender<L> for AsyncPrice {
    fn render(
        self,
        cx: &mut WebContext<L>,
    ) -> RenderOutcome<impl Future<Output = Result<(), WriteError>>> {
        RenderOutcome::pending(async move {
            let amount = 42_u32;
            html! {
                <span>"$"{amount}</span>
            }
            .render(cx)
            .resolve()
            .await
        })
    }
}

Ordinary authored implementations are generic only over L: DataLoader. WebContext<L> owns the default reusable RenderBuffer; the second B parameter is reserved for lower-level custom-writer integrations.

Driving an async render

WebRender never blocks to render itself. It returns a RenderOutcome, Done(result) on the sync path or Pending(future) on the async path, and a driver at the route or adapter boundary resolves it.

For framework routes, use RouteResponse::ssr_async(...), ssr_async_with_loader(...), or the ssr_stream* constructors. For custom adapters and tests, proa_core ships drivers:

DriverUse it when
render_to_vec_async(root)You want an owned contiguous byte buffer.
render_to_string_async(root)You want a UTF-8 string and accept a panic on invalid UTF-8.
try_render_to_string_async(root)You want a UTF-8 string with explicit error handling.
render_into_async(&mut buf, root)You want to reuse a caller-owned RenderBuffer.
render_in_context_async(&mut cx, root)You need to customize WebContext first.
src/adapter.rs
use std::sync::Arc;

use proa_core::{render_in_context_async, WebContext};

let mut out = Vec::new();
let mut cx = WebContext::with_loader_buffer(
    &mut out,
    Arc::new(loader),
);

render_in_context_async(&mut cx, PageWithData).await?;

Good to know: Streaming render modes also need the matching framework state installed, stream recorders and chunk flushers. Use the framework response helpers unless you are writing that adapter layer. See Streaming SSR.

Next steps

Search

Type at least 2 characters