workers-rsx
A RSX-like templating engine for Cloudflare Workers in Rust.
Write HTML templates using a familiar JSX/TSX-like syntax directly in Rust, with compile-time validation and zero runtime overhead. Inspired by RSX.
Installation
Add to your Cargo.toml:
[dependencies]
workers-rsx = "0.1"
Quick Start
use worker::{event, Context, Env, Request, Response, Result};
use workers_rsx::{component, rsx, view};
#[component]
fn Hello<'name>(name: &'name str) {
rsx! {
<div>
<h1>{"Hello, "} {name}!</h1>
</div>
}
}
#[event(fetch)]
pub async fn main(_req: Request, _env: Env, _ctx: Context) -> Result<Response> {
let name = "world";
view! { <Hello {name} /> }
}
Features
Components
Define reusable components with the #[component] attribute. Components are compiled to structs that implement the Render trait.
#[component]
fn Button<'label>(label: &'label str) {
rsx! { <button class="btn">{label}</button> }
}
Use components with an uppercase name:
rsx! { <Button label="Click me" /> }
Elements & Attributes
HTML elements use lowercase names. Attribute values can be string literals or expressions in braces:
rsx! {
<div class="container" id="main">
<a href="/about">About</a>
</div>
}
Use braces for dynamic values:
let cls = "active";
rsx! { <div class={cls}>x</div> }
Text
Unquoted text renders as static text (HTML-escaped at render time):
rsx! { <p>Hello, world!</p> }
For text containing special characters (emojis, unicode escapes, precise whitespace before siblings), use braced string literals:
rsx! { <p>{"Hello, "} {name}!</p> }
rsx! { <span>{"⚡ Electric"}</span> }
Expression Blocks
Rust expressions in braces are rendered and HTML-escaped:
rsx! { <span>{user.name}</span> }
Text Directive {text expr}
Forces an expression to render as escaped text:
rsx! { <p>{text some_value}</p> }
HTML Directive {html expr}
Injects raw HTML without escaping — use with trusted content only:
rsx! { <div>{html "<strong>bold</strong>"}</div> }
Boolean Attributes
Bare attribute names render as boolean HTML attributes:
rsx! { <input type="text" disabled /> }
Prop Shorthand
When a variable name matches the prop name, use shorthand syntax:
let name = "Pikachu";
rsx! { <Heading {name} /> }
Fragments
Group multiple elements without a wrapper tag:
rsx! {
<>
<h1>Title</h1>
<p>Body</p>
</>
}
Void Elements
Standard HTML void elements (br, hr, img, input, meta, link, etc.) render without a closing tag:
rsx! { <br /> }
Conditional Rendering
Use if, else if, and else directly in templates:
rsx! {
<div>
if is_admin {
<span>Admin</span>
} else if is_mod {
<span>Moderator</span>
} else {
<span>User</span>
}
</div>
}
List Rendering
Use for..in loops to render lists:
rsx! {
<ul>
for item in items.iter() {
<li>{text item}</li>
}
</ul>
}
Match Expressions
Pattern matching with optional guards:
rsx! {
<div>
match status {
"active" => {
<span class="green">Active</span>
},
"pending" if count > 0 => {
<span class="yellow">{"Pending ("} {count})</span>
},
_ => {
<span>Unknown</span>
},
}
</div>
}
Scoped let Bindings
Declare local variables inside templates. They are scoped to the current element's children:
rsx! {
<div>
let greeting = format!("Hello, {}!", name);
<p>{greeting}</p>
</div>
}
HTML5 Doctype
Use the built-in HTML5Doctype component:
use workers_rsx::html::HTML5Doctype;
rsx! {
<>
<HTML5Doctype />
<html>
<head><title>My Page</title></head>
<body><p>Hello</p></body>
</html>
</>
}
Raw HTML Macro
For raw HTML outside of templates:
use workers_rsx::raw;
let content = raw!("<p>Unescaped HTML</p>");
Tailwind CSS (Built-in)
Tailwind CSS support is built-in via tailwind-rs-core. The html! and view! macros automatically extract static class attribute values at compile time, generate the corresponding Tailwind CSS at runtime, and prepend a <style> tag to the output. No extra setup or config is needed.
view! {
<div class="bg-gray-100 min-h-screen py-8">
<h1 class="text-4xl font-bold text-center text-gray-800">Hello</h1>
<p class="text-lg text-gray-600">Styled with Tailwind!</p>
</div>
}
Classes inside if, for, and match blocks are also extracted. Dynamic class expressions (class={expr}) are not extracted at compile time — use static string values for automatic Tailwind CSS generation.
If no Tailwind classes are found, no <style> tag is emitted.
Macros
| Macro |
Description |
rsx! |
Returns a renderable component tree (no Tailwind processing) |
html! |
Renders the template to a String with auto-generated Tailwind CSS |
view! |
Renders and returns a worker::Response with auto-generated Tailwind CSS |
css! |
Parses CSS syntax at compile time and returns a &'static str |
#[component] |
Defines a function component struct that implements Render |
raw! |
Creates a raw (unescaped) HTML value |
Implementing Render
You can implement Render for your own types:
use workers_rsx::Render;
use std::fmt::{Result, Write};
struct MyWidget {
label: String,
}
impl Render for MyWidget {
fn render_into<W: Write>(self, writer: &mut W) -> Result {
write!(writer, "<div class=\"widget\">{}</div>", self.label)
}
}
RSX Feature Compatibility
Compatibility with the RSX specification:
| RSX Feature |
workers-rsx |
Notes |
Unquoted text Hello |
✅ |
HTML-escaped static text |
Braced strings {"text"} |
✅ |
For emojis, unicode, precise whitespace |
Expression containers {expr} |
✅ |
HTML-escaped dynamic values |
Text directive {text expr} |
✅ |
Explicit escaped text rendering |
HTML directive {html expr} |
✅ |
Raw unescaped HTML injection |
Conditional if / else if / else |
✅ |
Full conditional chain support |
List rendering for..in |
✅ |
for pat in iter { ... } |
Pattern matching match |
✅ |
With guard clauses |
Prop shorthand {name} |
✅ |
Variable name matches prop name |
| Boolean attributes |
✅ |
<input disabled /> |
| Void elements |
✅ |
br, hr, img, input, meta, etc. |
Scoped locals let / const |
✅ |
let bindings in template body |
| Components |
✅ |
#[component] attribute macro |
Fragments <>...</> |
✅ |
Wrapper-free grouping |
| String attribute values |
✅ |
class="foo" without braces |
Spread attributes {...props} |
❌ |
Not yet supported |
| Tailwind CSS |
✅ |
Auto-generated via tailwind-rs-core |
Todo Example
A full CRUD app using htmx with a Redux-style state management pattern. Uses Reducer trait for state/action/view separation, automatic HTML diffing via OOB swaps, and client-side state stored in the DOM.
use serde::{Deserialize, Serialize};
use worker::{event, Context, Env, Request, Response, Result};
use workers_rsx::html::HTML5Doctype;
use workers_rsx::{component, handle, page, rsx, ActionJson};
use workers_rsx::Reducer;
#[derive(Clone, Debug, Serialize, Deserialize)]
struct Todo {
id: String,
name: String,
done: bool,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Default)]
enum Filter {
#[default]
All,
Active,
Completed,
}
#[derive(Clone, Debug, Serialize, Deserialize, Default)]
struct PageState {
todos: Vec<Todo>,
next_id: u32,
editing: Option<String>,
filter: Filter,
action: Option<PageAction>,
}
impl PageState {
fn items_left(&self) -> usize {
self.todos.iter().filter(|t| !t.done).count()
}
}
#[derive(Clone, Debug, Serialize, Deserialize, ActionJson)]
#[serde(tag = "type")]
enum PageAction {
AddTodo { name: String },
ToggleTodo { id: String },
DeleteTodo { id: String },
EditTodo { id: String },
UpdateTodo { id: String, name: String },
ClearCompleted,
SetFilter { filter: Filter },
}
impl Reducer for PageState {
type Action = PageAction;
fn initial() -> Self {
Self {
todos: vec![
Todo { id: "1".into(), name: "Taste htmx".into(), done: true },
Todo { id: "2".into(), name: "Buy a unicorn".into(), done: false },
],
next_id: 3,
editing: None,
filter: Filter::All,
action: None,
}
}
fn reduce(&self, action: PageAction) -> Self {
let mut next = self.clone();
match action {
PageAction::AddTodo { name } => {
if !name.is_empty() {
let id = next.next_id.to_string();
next.next_id += 1;
next.todos.push(Todo { id, name, done: false });
}
}
PageAction::ToggleTodo { id } => {
if let Some(todo) = next.todos.iter_mut().find(|t| t.id == id) {
todo.done = !todo.done;
}
}
PageAction::DeleteTodo { id } => {
next.todos.retain(|t| t.id != id);
}
PageAction::EditTodo { id } => {
next.editing = Some(id);
}
PageAction::UpdateTodo { id, name } => {
if let Some(todo) = next.todos.iter_mut().find(|t| t.id == id) {
if !name.is_empty() {
todo.name = name;
}
}
next.editing = None;
}
PageAction::ClearCompleted => {
next.todos.retain(|t| !t.done);
}
PageAction::SetFilter { filter } => {
next.filter = filter;
}
}
next.action = None;
next
}
}
#[page(PageState)]
fn TodosPage(state: PageState) {
let left = state.items_left();
let left_str = left.to_string();
let label = if left == 1 { "item" } else { "items" };
let all_class = if state.filter == Filter::All { "selected" } else { "" };
let active_class = if state.filter == Filter::Active { "selected" } else { "" };
let completed_class = if state.filter == Filter::Completed { "selected" } else { "" };
let filtered_todos: Vec<&Todo> = state.todos.iter().filter(|t| match state.filter {
Filter::Active => !t.done,
Filter::Completed => t.done,
Filter::All => true,
}).collect();
rsx! {
<>
<HTML5Doctype />
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{"HTMX • TodoMVC"}</title>
<link rel="stylesheet" href="https://unpkg.com/todomvc-common@1.0.5/base.css" />
<link rel="stylesheet" href="https://unpkg.com/todomvc-app-css@2.4.2/index.css" />
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
<script src="https://unpkg.com/htmx-ext-json-enc@2.0.1/json-enc.js"></script>
</head>
<body hx-ext="json-enc">
<section class="todoapp">
<header class="header">
<h1>todos</h1>
<form hx-post="/" hx-target="body" hx-swap="none">
<input type="hidden" name="type"
value="AddTodo" />
<input id="new-todo-input" class="new-todo" name="name"
placeholder="What needs to be done?" />
</form>
</header>
<section class="main">
<ul id="todo-list" class="todo-list">
for todo in filtered_todos.iter() {
let is_editing = state.editing.as_deref() == Some(todo.id.as_str());
<TodoItem
id={todo.id.clone()}
name={todo.name.clone()}
done={todo.done}
editing={is_editing} />
}
</ul>
</section>
<footer class="footer">
<span id="todo-count" class="todo-count">
<strong>{left_str}</strong>
{" "}{label} left
</span>
<ul id="filters" class="filters">
<li><a class={all_class} hx-post="/" hx-target="body" hx-swap="none"
hx-ext="json-enc" hx-vals={PageAction::SetFilter { filter: Filter::All }}>All</a></li>
<li><a class={active_class} hx-post="/" hx-target="body" hx-swap="none"
hx-ext="json-enc" hx-vals={PageAction::SetFilter { filter: Filter::Active }}>Active</a></li>
<li><a class={completed_class} hx-post="/" hx-target="body" hx-swap="none"
hx-ext="json-enc" hx-vals={PageAction::SetFilter { filter: Filter::Completed }}>Completed</a></li>
</ul>
<button class="clear-completed"
hx-post="/" hx-target="body" hx-swap="none"
hx-vals={PageAction::ClearCompleted}>
Clear completed
</button>
</footer>
</section>
<footer class="info">
<p>Double-click to edit a todo</p>
<p>Created with <a href="https://github.com/pyrossh/workers-rsx">workers-rsx</a></p>
<p>Part of <a href="http://todomvc.com">TodoMVC</a></p>
</footer>
</body>
</html>
</>
}
}
#[component]
fn TodoItem(id: String, name: String, done: bool, editing: bool) {
let li_id = format!("todo-{}", id);
let li_class = match (done, editing) {
(_, true) => "editing",
(true, _) => "completed",
_ => "",
};
rsx! {
<li id={li_id} class={li_class}>
if editing {
<form id="edit-form" hx-post="/" hx-target="body" hx-swap="none" hx-ext="json-enc">
<input type="hidden" name="type"
value="UpdateTodo" />
<input type="hidden" name="id" value={id} />
<input class="edit" type="text" name="name" value={name} />
</form>
} else {
<div class="view">
if done {
<input class="toggle" type="checkbox" checked
hx-post="/" hx-target="body" hx-swap="none"
hx-ext="json-enc" hx-vals={PageAction::ToggleTodo { id: id.clone() }} />
} else {
<input class="toggle" type="checkbox"
hx-post="/" hx-target="body" hx-swap="none"
hx-ext="json-enc" hx-vals={PageAction::ToggleTodo { id: id.clone() }} />
}
<label hx-post="/" hx-target="body" hx-swap="none"
hx-ext="json-enc" hx-vals={PageAction::EditTodo { id: id.clone() }}>
{name}
</label>
<button class="destroy"
hx-post="/" hx-target="body" hx-swap="none"
hx-ext="json-enc" hx-vals={PageAction::DeleteTodo { id: id.clone() }}>
</button>
</div>
}
</li>
}
}
#[event(fetch)]
pub async fn main(req: Request, _env: Env, _ctx: Context) -> Result<Response> {
handle::<PageState, TodosPage>(req).await
}
How it works
Reducer trait — Define initial() state and a reduce() function. The view component is passed as a type parameter to handle
ActionJson derive — Generates Display and Into<Cow<str>> for enum variants, so actions serialize to JSON automatically when used as attribute values
handle() — On GET, renders the full page and injects state into the DOM. On POST (htmx), decodes state, dispatches the action through reduce(), diffs the old and new HTML, and returns only changed elements via hx-swap-oob
- State transport — State is stored in a
<script type="application/json"> tag in the DOM and included in every htmx request automatically
License
MIT