Docs
Tutorial: A Product Page
Server-render a catalog, then add a quantity stepper that still works with JavaScript off.
Here you'll be building a sample product page! Build a small product catalog and a product page whose add-to-cart form is a plain HTML form. Then add an interactive quantity stepper with a live subtotal.
Start from the project created in Getting Started:
proa new site shop-demo --template marketing --tailwind --yes
cd shop-demo
proa dev --open
proa dev builds the Tailwind assets before starting the server, runs the
binary through the linked pipeline, and restarts on changes. Open tabs reload
themselves after each rebuild, and stylesheet edits are applied without
navigating. See Live reload for the flags.
The generated site already has a home page, a RootDocument layout in
src/layouts/root.rs, a router in src/routes.rs, and health endpoints. You
will add files beside them and register new routes between the
proa:route-specs markers.
Target Files
src/components/mod.rsproduct_card.rsquantity_stepper.rsdata/mod.rsproducts.rsendpoints/mod.rscart.rslayouts/root.rspages/mod.rsproducts.rsproduct.rsrsjs_assets.rsroutes.rsmain.rsProduct Data
Prices are whole dollars as i64 so the browser can multiply them later without
a money-formatting helper.
#[derive(Debug, Clone, Copy)]
pub struct Product {
pub slug: &'static str,
pub path: &'static str,
pub name: &'static str,
pub category: &'static str,
pub price_usd: i64,
pub summary: &'static str,
}
pub const PRODUCTS: &[Product] = &[
Product {
slug: "atlas-jacket",
path: "/products/atlas-jacket",
name: "Atlas Jacket",
category: "Men's Weatherproof Shell",
price_usd: 248,
summary: "Weatherproof shell with a quiet technical finish.",
},
Product {
slug: "field-pack",
path: "/products/field-pack",
name: "Field Pack",
category: "Everyday Backpack",
price_usd: 168,
summary: "Structured everyday pack with laptop and camera storage.",
},
Product {
slug: "merino-tee",
path: "/products/merino-tee",
name: "Merino Tee",
category: "Men's Base Layer",
price_usd: 78,
summary: "Lightweight base layer for travel, training, and daily wear.",
},
];
pub fn find_product(slug: &str) -> Option<&'static Product> {
PRODUCTS.iter().find(|product| product.slug == slug)
}
pub mod products;
// The generated main.rs declares each module explicitly. Add the new one:
mod data;
Product Card
The whole card is the link, and the image slot is a placeholder box until you have real photography.
use proa_core::{DataLoader, WebContext, WebRenderSync, WriteError};
use proa_macros::html_sync;
use crate::data::products::Product;
pub struct ProductCard {
pub product: &'static Product,
}
impl<L: DataLoader> WebRenderSync<L> for ProductCard {
fn render(self, cx: &mut WebContext<L>) -> Result<(), WriteError> {
let product = self.product;
html_sync! {
<a class="grid gap-3" href={product.path}>
<div class="flex aspect-square w-full items-center justify-center rounded-lg bg-zinc-100">
<span class="text-xs uppercase tracking-[0.25em] text-zinc-400">
{product.name}
</span>
</div>
<div>
<h2 class="text-base font-medium text-zinc-950">{product.name}</h2>
<p class="text-sm text-zinc-500">{product.category}</p>
<p class="mt-2 text-base font-medium text-zinc-950">"$"{product.price_usd}</p>
</div>
</a>
}
.render(cx)
}
}
pub mod badge;
pub mod button;
pub mod card;
pub mod product_card;
pub use badge::Badge;
pub use button::Button;
pub use card::Card;
The scaffold's mod.rs already declares the vendored Badge, Button, and
Card, which the home page uses, so append to it rather than replacing it.
Routes
Two pages: a listing that maps over PRODUCTS, and a detail page whose
add-to-cart control is a native form posting sku and qty. There is no
JavaScript in either one.
Each handler wraps its page in the generated RootDocument layout and hands it
to RouteResponse::ssr_async, the same shape as the generated src/pages/home.rs.
RootDocument is async-capable, so a synchronous page goes in through
proa_core::to_async. A loop body is its own template, so it needs its own
html_sync! block.
use axum::response::IntoResponse;
use proa_core::{to_async, DataLoader, WebContext, WebRenderSync, WriteError};
use proa_framework_axum::RouteResponse;
use proa_macros::html_sync;
use crate::components::product_card::ProductCard;
use crate::data::products::PRODUCTS;
use crate::layouts::RootDocument;
pub async fn handler() -> impl IntoResponse {
RouteResponse::ssr_async(RootDocument {
title: "Products",
content: to_async(ProductsPage),
})
}
struct ProductsPage;
impl<L: DataLoader> WebRenderSync<L> for ProductsPage {
fn render(self, cx: &mut WebContext<L>) -> Result<(), WriteError> {
html_sync! {
<main class="mx-auto grid max-w-6xl gap-8 px-6 py-12">
<h1 class="text-3xl font-medium text-zinc-950">"Products"</h1>
<ul class="grid gap-8 sm:grid-cols-2 lg:grid-cols-3">
{for product in PRODUCTS {
html_sync! { <li>{ProductCard { product }}</li> }
}}
</ul>
</main>
}
.render(cx)
}
}
use axum::{extract::Path, http::StatusCode, response::IntoResponse};
use proa_core::{to_async, DataLoader, WebContext, WebRenderSync, WriteError};
use proa_framework_axum::RouteResponse;
use proa_macros::html_sync;
use crate::data::products::{find_product, Product};
use crate::layouts::RootDocument;
pub async fn handler(Path(slug): Path<String>) -> Result<impl IntoResponse, StatusCode> {
let product = find_product(&slug).ok_or(StatusCode::NOT_FOUND)?;
Ok(RouteResponse::ssr_async(RootDocument {
title: product.name,
content: to_async(ProductPage { product }),
}))
}
struct ProductPage {
product: &'static Product,
}
impl<L: DataLoader> WebRenderSync<L> for ProductPage {
fn render(self, cx: &mut WebContext<L>) -> Result<(), WriteError> {
let product = self.product;
html_sync! {
<main class="mx-auto max-w-6xl px-6 py-10">
<nav class="flex items-center gap-2 text-xs text-zinc-500">
<a href="/products">"Products"</a>
<span>"/"</span>
<span class="text-zinc-900">{product.name}</span>
</nav>
<div class="mt-6 flex flex-col gap-10 lg:flex-row">
<div class="lg:w-[55%]">
<div class="flex aspect-square w-full items-center justify-center rounded-lg bg-zinc-100">
<span class="text-xs uppercase tracking-[0.25em] text-zinc-400">
{product.name}
</span>
</div>
</div>
<div class="lg:w-[45%]">
<h1 class="text-[28px] font-medium leading-tight text-zinc-950">
{product.name}
</h1>
<p class="mt-1 text-base text-zinc-500">{product.category}</p>
<p class="mt-4 text-xl font-medium text-zinc-950">
"$"{product.price_usd}
</p>
<p class="mt-6 text-base leading-7 text-zinc-600">{product.summary}</p>
<form method="post" action="/cart" class="mt-8">
<input type="hidden" name="sku" value={product.slug} />
<input type="hidden" name="qty" value="1" />
<button
class="mt-6 flex h-[60px] w-full items-center justify-center rounded-full bg-zinc-950 text-lg font-medium text-white transition hover:opacity-80"
type="submit"
>
"Add to Bag"
</button>
</form>
<div class="mt-8 border-t border-zinc-200 pt-2 text-sm text-zinc-600">
<p class="py-2">"Free standard shipping on orders over $150."</p>
<p class="py-2">"Free 60-day returns."</p>
</div>
</div>
</div>
</main>
}
.render(cx)
}
}
pub mod home;
pub mod product;
pub mod products;
Form Action
Add the POST handler beside the generated health endpoints, then register all
three routes in src/routes.rs.
use axum::{extract::Form, response::Redirect};
use serde::Deserialize;
#[derive(Deserialize)]
pub struct AddToCart {
sku: String,
qty: u32,
}
pub async fn add_to_cart(Form(form): Form<AddToCart>) -> Redirect {
tracing::info!(sku = %form.sku, qty = form.qty, "add to cart");
Redirect::to("/products")
}
pub mod cart;
pub mod health;
use axum::routing::{get, post};
// ... inside router(), between the markers the generator maintains:
let (router, metadata) = router_from_spec(vec![
// proa:route-specs:start
Route::endpoint("/", get(pages::home::handler)),
Route::endpoint("/products", get(pages::products::handler)),
Route::endpoint("/products/:slug", get(pages::product::handler)),
Route::endpoint("/cart", post(endpoints::cart::add_to_cart)),
// proa:route-specs:end
]);
Two pieces of Axum vocabulary are worth naming here, because they look unusual the first time:
Form<T>is a newtype wrapper,struct Form<T>(pub T). The type is the instruction: it tells Axum to parse the request body asapplication/x-www-form-urlencodedintoT. Swap it forJson<T>,Path<T>, orQuery<T>to read a different part of the request.Form(form):is ordinary Rust pattern destructuring in the parameter position, the same thing aslet Form(form) = ...written inline. You can writeform: Form<AddToCart>instead and reach the value throughform.0.
Deserializing into a typed struct rather than a HashMap means a missing or
non-numeric qty is rejected by the extractor with a 422 before your handler
runs.
Open http://localhost:3000/products/atlas-jacket and submit the form. It posts
sku=atlas-jacket and qty=1, then redirects. No JavaScript has been involved
so far.
The Island
Now make the quantity adjustable in the browser, without giving up anything above.
rsjs = { version = "0.2", git = "https://github.com/Proa-Labs/proa.git" }
rsjs re-exports the #[rsjs] attribute, so no separate macro crate is
needed, and the scaffold already depends on serde.
use proa_core::{DataLoader, WebContext, WebRenderSync, WriteError};
use proa_macros::html_sync;
use rsjs::{rsjs, signal};
pub struct QuantityStepper {
pub unit_price: i64,
}
#[rsjs(component, client)]
impl<L: DataLoader> WebRenderSync<L> for QuantityStepper {
fn render(self, cx: &mut WebContext<L>) -> Result<(), WriteError> {
let qty = signal(1_i64);
let unit_price: rsjs::Signal<i64> = signal(self.unit_price);
let subtotal = rsjs::computed(|| qty.get() * unit_price.get());
html_sync! {
<div>
<div class="flex items-center justify-between">
<span class="text-base font-medium text-zinc-950">"Quantity"</span>
<span class="text-sm text-zinc-500" aria-live="polite">
"Subtotal $"{subtotal.get()}
</span>
</div>
<div class="mt-3 flex items-center gap-2">
<button
type="button"
class="h-12 w-12 rounded-lg border border-zinc-300 text-lg transition hover:border-zinc-950 disabled:border-zinc-100 disabled:bg-zinc-100 disabled:text-zinc-400"
aria-label="Decrease quantity"
disabled={qty.get() == 1}
on_click={|_| qty.update(|q| q - 1)}
>
"-"
</button>
<span class="flex h-12 w-16 items-center justify-center rounded-lg border border-zinc-300 text-base font-medium">
{qty.get()}
</span>
<button
type="button"
class="h-12 w-12 rounded-lg border border-zinc-300 text-lg transition hover:border-zinc-950"
aria-label="Increase quantity"
on_click={|_| qty.update(|q| q + 1)}
>
"+"
</button>
</div>
<input type="hidden" name="qty" value={qty.get()} />
</div>
}
.render(cx)
}
}
pub mod badge;
pub mod button;
pub mod card;
pub mod product_card;
pub mod quantity_stepper;
pub use badge::Badge;
pub use button::Button;
pub use card::Card;
use crate::components::quantity_stepper::QuantityStepper;
// ...
<form method="post" action="/cart" class="mt-8">
<input type="hidden" name="sku" value={product.slug} />
{QuantityStepper { unit_price: product.price_usd }}
<button
class="mt-6 flex h-[60px] w-full items-center justify-center rounded-full bg-zinc-950 text-lg font-medium text-white transition hover:opacity-80"
type="submit"
>
"Add to Bag"
</button>
</form>
Four things in quantity_stepper.rs carry the whole idea:
#[rsjs(component, client)]marks an otherwise ordinaryWebRenderSyncimpl for analysis.componentasks the compiler to analyze it;clientgrants permission to originate browser behavior.signal(1_i64)is island-local state.signal(self.unit_price)seeds a prop into browser-visible state once, at mount. The explicitrsjs::Signal<i64>annotation gives the arithmetic a proven fixed-width integer domain, which the compiler requires before it will lower*into JavaScript; without it you getrsjs/integer-domain-unproven.rsjs::computed(...)derives the subtotal. It recomputes in the browser when either input changes, and it is evaluated once on the server for the initial HTML.- The hidden input is the bridge back to the plain form. Its
valueis a reactive attribute, and the runtime writes the DOMvalueproperty, so the form submits the current quantity.
In product.rs the stepper replaces the fixed hidden qty input. An island is
invoked exactly like any other component: a struct literal in a slot. That is
the component running in the demo at the top of this page.
Serving the Island
The compiler emits a JavaScript module per island and a shared runtime. A hand-written Axum app has to do two things with them: announce the ones a response actually used, and serve them.
RsjsRuntimeTags must render as the last thing in <body>. The snapshot only
sees islands rendered so far, so these tags have to come after the page content.
A page with no islands plans nothing and emits no tags.
The component_ fallback in rsjs_assets.rs matters: a component fragment is
compiled separately from the island that mounts it, and the page preloads both.
use proa_core::{to_async, WebRenderSync};
pub struct RsjsRuntimeTags;
impl<L: DataLoader> WebRenderSync<L> for RsjsRuntimeTags {
fn render(self, cx: &mut WebContext<L>) -> Result<(), WriteError> {
let artifacts = cx.rsjs_artifacts_snapshot();
let plan = rsjs::manifest()
.page_runtime_plan(artifacts.rendered_islands())
.expect("every rendered island resolves in the linked manifest");
plan.write_html(&mut cx.out, None)
}
}
// ... and in the generated document body, which is an async `html!` template:
<body>
{content}
{to_async(RsjsRuntimeTags)}
</body>
use axum::{
extract::Path,
http::{header, StatusCode},
response::{IntoResponse, Response},
routing::get,
Router,
};
/// Generic over the app state so it merges into the generated `Router<AppState>`.
pub fn router<S>() -> Router<S>
where
S: Clone + Send + Sync + 'static,
{
Router::new()
.route("/rsjs-runtime.js", get(runtime))
.route("/rsjs/island/:file", get(island))
}
async fn runtime() -> impl IntoResponse {
(js_headers(), rsjs::runtime_assets::CORE_JS)
}
async fn island(Path(file): Path<String>) -> Response {
let asset_id = file.strip_suffix(".js").unwrap_or(file.as_str());
let fragment_id = asset_id.strip_prefix("component_").unwrap_or(asset_id);
let manifest = rsjs::manifest();
let source = manifest
.islands
.iter()
.find(|entry| entry.asset_id == asset_id)
.map(|entry| entry.js_source)
.or_else(|| {
manifest
.component_fragments
.iter()
.find(|entry| entry.asset_id == asset_id || entry.asset_id == fragment_id)
.map(|entry| entry.js_source)
});
match source {
Some(source) => (js_headers(), source).into_response(),
None => (StatusCode::NOT_FOUND, "unknown rsjs asset").into_response(),
}
}
fn js_headers() -> [(header::HeaderName, &'static str); 2] {
[
(header::CONTENT_TYPE, "application/javascript; charset=utf-8"),
(header::CACHE_CONTROL, "no-store"),
]
}
mod rsjs_assets;
use crate::rsjs_assets;
// ... in the chain after router_from_spec, before .with_state(state):
router
.route("/healthz", get(endpoints::health::live))
.route("/readyz", get(endpoints::health::ready))
.merge(rsjs_assets::router())
// proa:explicit-routes:start
.route("/index.md", get(pages::home::markdown))
// proa:explicit-routes:end
.nest_service("/static", static_assets::service())
.with_state(state)
Verify
proa dev restarts on its own after the change; if you stopped it, run it
again. Open http://localhost:3000/products/atlas-jacket and check three
things.
The server sent working HTML. View source. The stepper is real markup with the correct starting state, wrapped in an island boundary next to a small seed script:
<rsjs-island data-rsjs-island-root="rsjs_owner_...">
<div>
<div class="flex items-center justify-between">
<span class="...">Quantity</span>
<span class="..." aria-live="polite">Subtotal $<span data-rsjs-bind="6">248</span></span>
</div>
<div class="mt-3 flex items-center gap-2">
<button type="button" class="..." aria-label="Decrease quantity" disabled data-rsjs-attr="8" data-rsjs-handler="8">-</button>
<span class="..." data-rsjs-bind="10">1</span>
<button type="button" class="..." aria-label="Increase quantity" data-rsjs-handler="11">+</button>
</div>
<input type="hidden" name="qty" value="1" data-rsjs-attr="13">
</div>
</rsjs-island>
<script type="application/json" data-rsjs-island="rsjs_owner_...">{"signals":["1","248"]}</script>
The data-rsjs-* attributes are compiler-owned markers that tell the runtime
which nodes to bind on hydration. The seed script carries the exact initial
signal values, so the browser rebuilds state without re-fetching anything.
The minus button is already disabled because qty starts at 1. That state was
computed on the server, not after hydration.
The island works. Click + twice. The quantity reads 3, the subtotal
reads $744, the minus button re-enables, and the hidden input now submits
qty=3.
It degrades. Disable JavaScript in devtools and reload. The stepper buttons
do nothing, but the hidden input still carries value="1", so "Add to Bag"
posts sku=atlas-jacket and qty=1 exactly as it did before the island
existed. Nothing on the page is blank or broken.
That is the whole trade. The island is opt-in behavior layered onto HTML that already works, not the mechanism that makes the page appear.
Validate
cargo fmt --all
cargo check
proa fmt --check --verify src
proa lint src
cargo check still works on a project with islands. cargo build and
cargo run do not: the link step deliberately fails so that a binary is never
shipped without its compiled island modules. Use proa dev, proa run, or
proa build instead.
Next
| Add | Read |
|---|---|
| More island shapes | Islands: when and why |
| Signal semantics and props | RSJS Signals and Component props |
| Server data loading | Framework Data Loading |
| Richer forms and typed actions | Forms and actions |
| Production cache rules | Page caching |