Hedronite · Frontend Dev · Rust · Thu 2026-10-22

Topcoat 0.10 from zero server-rendered Rust pages, browser signals, and shards

Render every byte on the server, let small Rust expressions re-run in the browser, and send only the fragments that need fresh data back to the server.

Lesson Class: Dev (Rust web UI · Topcoat)
Tested with: topcoat 0.10.0 · topcoat-cli 0.10.0 · tokio 1.54.1 · rustc/cargo 1.99.0 · macOS 27 + Linux · headless Chrome
Lab: topcoat_shelf · cargo test (12 tests: 9 + 3 pitfalls) · playwright-core browser probe · curl transcript
Source: Topcoat 0.10.0 README and guides · Web Development with Node and Express, ch. 1 and 7 · HTML5 Pocket Reference · JavaScript: The Definitive Guide §15.2.2
HTML First
Pages, layouts and components render to finished, escaped HTML on the server.
Signals Stay Local
A $(...) expression renders once on the server and re-runs in the browser with no request.
Shards Are Endpoints
A shard re-renders on the server over its own POST endpoint, so it guards itself.
Send the page once; send back only what changed.

Versions used: topcoat 0.10.0 and topcoat-cli 0.10.0, tokio 1.54.1, http 1.5.0 (tests only), rustc and cargo 1.99.0 (Topcoat declares a minimum of Rust 1.98). Tested on macOS 27 (Apple silicon) and Linux, with browser checks in headless Chrome via playwright-core 1.64.1.

The Topcoat README describes the project as "Early-stage and experimental. Expect breaking changes." The runtime documentation calls the runtime "highly experimental". Pin the exact version, as the example project below does.

The problem

A small team has a Rust backend and a catalogue page that lists books. The usual way to add a browser interface is a JSON API in Rust plus a single-page app in TypeScript. That means two languages, two build pipelines, and the same data shapes declared twice, once as Rust structs and once as TypeScript types. The API layer exists only to feed the browser.

Yet most of the page is plain HTML. Only three things move: a counter, a show-and-hide toggle, and a search box whose results update as you type. Old-style server templates avoid the second stack, but every click then costs a full page reload, which is a poor fit for a search box.

The team wants a short list of things:

The rest of this article builds that page with Topcoat, a full-stack Rust web framework from the Tokio project. It also shows the mistakes that bite first.

The idea in plain words

Topcoat renders every byte of HTML on the server, in Rust, using async functions that can query a database directly. Inside that markup you write small $(...) expressions. These are ordinary type-checked Rust that the server evaluates for the first render and that Topcoat also translates to JavaScript, so the browser can run them again without asking the server. A shard is a component with its own POST endpoint: when one of its inputs changes, the browser calls that endpoint and swaps in only the re-rendered fragment.

This differs from a single-page app. Ethan Brown's Web Development with Node and Express defines a server-side application as one whose pages "are rendered on the server" and sent to the client. A client-side application, by contrast, "renders most of its own user interface" in the browser. Topcoat sits on the server-rendered side: the first response is finished HTML, not code that builds the page. It also differs from Rust UI frameworks that compile to WebAssembly. The README says of $(...) expressions: "No wasm bundle, no client build step". A common misreading is that Topcoat ships a compiled copy of your Rust app to the browser. It sends HTML, one JavaScript runtime file, and small translated expressions.

Concept A: server-rendered views

A view is a value that renders to HTML, written with the view! macro. Inside view!, text goes in quotes, a Rust expression goes in parentheses, and a for loop repeats markup. A component is an async function marked #[component] that returns Result<impl View>, and markup calls it with named arguments. A page, declared with #[page("/...")], binds a handler to a URL path. A layout, declared with #[layout("/")], wraps every page under its path prefix and receives the inner page as a Slot, a value you embed in your own markup. Components are async so that they can query a database directly. That removes the separate API layer from the problem above, because the data never has to leave the server as JSON first.

The example project is a bookshelf site, topcoat_shelf, with a Cargo.toml and a thin main.rs.

[package]
name = "topcoat_shelf"
version = "0.1.0"
edition = "2024"
publish = false

[dependencies]
topcoat = "=0.10.0"
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }

[dev-dependencies]
http = "1"

The = in topcoat = "=0.10.0" pins the exact release, which matters because of the breaking-change warning: a plain "0.10.0" would let Cargo pick up a later 0.10.x on the next cargo update. Tokio gets two features: rt-multi-thread for the multi-threaded async runtime and macros for #[tokio::main]. The http crate defines Rust's Request and Response types. It sits under dev-dependencies because only the tests build requests by hand. The binary itself is three lines:

#[tokio::main]
async fn main() {
    topcoat::start(topcoat_shelf::router()).await.unwrap();
}

topcoat::start serves the router and reads the HOST and PORT environment variables, defaulting to 127.0.0.1:3000.

The library file starts with imports, the data set, the layout, and one component.

use topcoat::{
    Result,
    asset::{AssetBundle, RouterBuilderAssetExt},
    context::Cx,
    router::{
        Router, Slot,
        error::{bad_request, unauthorized},
        layout, page,
        request::headers,
        route,
    },
    runtime::{Event, RouterBuilderRuntimeExt, shard, signal},
    view::{View, component, view},
};

pub const BOOKS: &[&str] = &[
    "The Rust Programming Language",
    "Rust for Rustaceans",
    "Zero To Production In Rust",
    "Designing Data-Intensive Applications",
    "Database Internals",
];

pub const MAX_QUERY_CHARS: usize = 64;

#[layout("/")]
async fn root_layout(slot: Slot<'_>) -> Result<impl View> {
    Ok(view! {
        <!DOCTYPE html>
        <html>
            <head>
                <title>"Shelf"</title>
                topcoat::runtime::script()
            </head>
            <body>
                <nav><a href="/">"Home"</a> " " <a href="/search">"Search"</a></nav>
                <main>(slot)</main>
            </body>
        </html>
    })
}

#[component]
pub async fn greeting(name: &str) -> Result<impl View> {
    Ok(view! { <h1>"Hello, " (name) "!"</h1> })
}

The use list names every Topcoat feature the project needs, and later sections explain the runtime, asset, and shard imports. BOOKS is the whole data set. root_layout takes the Slot and returns a complete document, and (slot) inside <main> marks where each page lands. Because the layout is declared at /, it wraps all pages. greeting shows a typed parameter, name: &str, interpolated with (name) between two quoted text nodes. The bare topcoat::runtime::script() in the head is explained in Concept B.

A handler that is not a page, such as a plain-text health check, is a route, and layouts wrap pages only, so GET /api/health answers with the bare body ok. The router is built explicitly at the end of the file.

#[route(GET "/api/health")]
async fn health() -> Result<&'static str> {
    Ok("ok")
}

/// Builds the router around an already-loaded asset bundle.
pub fn router_with(bundle: AssetBundle) -> Router {
    Router::builder()
        .layout(root_layout)
        .page(home)
        .page(search)
        .page(members)
        .route(search_results)
        .route(member_notes)
        .route(health)
        .assets(bundle)
        .runtime()
        .build()
}

/// Loads the bundle that `topcoat asset bundle` wrote next to the binary.
pub fn router() -> Router {
    router_with(AssetBundle::load().expect("run `topcoat asset bundle` first"))
}

A Router maps a method and path to a handler. #[route(GET "/api/health")] declares a plain handler that returns the text ok. In router_with, the builder serves only what you add: one layout, three pages, and three routes. The two shard routes are covered in Concept C, and .assets(bundle) and .runtime() in Concept B. Splitting router_with from router lets the tests pass in an asset bundle loaded from a chosen directory. The router docs describe Router::handle as turning a Request into a Response without serve, so a test needs no socket.

// Shared helpers for the integration tests (included with `mod common;`).
#![allow(dead_code)]
use topcoat::{
    asset::AssetBundle,
    router::{Body, Router, to_bytes},
};

/// The bundle `topcoat asset bundle` writes next to the app binary
/// (target/debug/assets). Test binaries live one level down in deps/.
pub fn bundle() -> AssetBundle {
    let exe = std::env::current_exe().unwrap();
    let dir = exe.parent().unwrap().parent().unwrap().join("assets");
    AssetBundle::load_dir(&dir).unwrap_or_else(|e| {
        panic!(
            "{}: {e}; run `topcoat asset bundle` before `cargo test`",
            dir.display()
        )
    })
}

/// Sends one request through the router in memory and returns status,
/// content-type and body text.
pub async fn send(router: &Router, req: http::Request<Body>) -> (u16, String, String) {
    let res = router.handle(req).await;
    let status = res.status().as_u16();
    let ctype = res
        .headers()
        .get("content-type")
        .map(|v| v.to_str().unwrap().to_owned())
        .unwrap_or_default();
    let bytes = to_bytes(res.into_body(), usize::MAX).await.unwrap();
    (status, ctype, String::from_utf8(bytes.to_vec()).unwrap())
}

pub fn get(path: &str) -> http::Request<Body> {
    http::Request::get(path).body(Body::empty()).unwrap()
}

/// What the browser runtime sends to re-render a shard.
pub fn shard_post(path: &str, json: &'static str) -> http::Request<Body> {
    http::Request::post(path)
        .header("content-type", "application/json")
        .header("accept", "text/html")
        .body(Body::from(json))
        .unwrap()
}

send hands a request to router.handle, then collects the status code, the content-type header, and the body text, so each test can assert on all three. get and shard_post build the two request shapes the tests need; shard_post mimics what the browser runtime sends. bundle() loads the asset bundle from target/debug/assets: a test executable lives one directory lower, in deps/, so the function climbs one level. It uses AssetBundle::load_dir because AssetBundle::load only looks next to the executable.

The first test sends GET / through the router and checks the layout.

mod common;
use common::{bundle, get, send, shard_post};
use topcoat::{
    context::Cx,
    view::{ViewExt, view},
};
use topcoat_shelf::{filter, greeting, router_with};

#[tokio::test]
async fn home_is_wrapped_in_the_root_layout() {
    let (status, ctype, html) = send(&router_with(bundle()), get("/")).await;
    assert_eq!(status, 200);
    assert_eq!(ctype, "text/html; charset=utf-8");
    assert!(
        html.starts_with("<!DOCTYPE html><html><head><title>Shelf</title>"),
        "{html}"
    );
    assert!(html.contains("<main><!--::topcoat::signal("), "{html}");
    assert!(html.contains("<h1>Hello, reader!</h1>"), "{html}");
    println!("home: {} bytes", html.len());
}

The page / is not defined with any <html> tag of its own, yet the response starts with <!DOCTYPE html><html><head><title>Shelf</title>. That proves the layout wrapped it. The body contains <h1>Hello, reader!</h1> from the greeting component, and the test prints home: 1711 bytes, matching the content-length that curl reports for the same page. The <main><!--::topcoat::signal( check previews Concept B: the page's state arrives as an HTML comment right inside the layout's <main>.

Escaping comes next. Ethan Brown's chapter on Handlebars, a template language for Node.js, shows that it escapes <b>Buttercup</b> into entities by default and offers a template-level opt-out: three curly brackets, as in {{{name}}}. Topcoat also escapes by default, but its opt-out is not template syntax. According to the view! docs, everything pushed through the push_* methods is escaped or validated for its position. Only the push_*_unescaped methods of a custom view-parts trait skip escaping, and they must only receive trusted markup. This matters because otherwise a name containing a <script> tag would run in every reader's browser.

#[tokio::test]
async fn text_in_parentheses_is_escaped() {
    let cx = &Cx::default();
    let html = view! { cx => greeting(name: "<script>alert(1)</script>") }
        .single()
        .await
        .unwrap()
        .render(cx);
    println!("{html}");
    assert_eq!(
        html,
        "<h1>Hello, &lt;script&gt;alert(1)&lt;/script&gt;!</h1>"
    );
}

Here view! renders a component outside any page, using the form view! { cx => greeting(...) } with a default Cx. A Cx is the per-request context value that Topcoat passes to pages, shards, and guard functions. The test passes hostile text as the name argument, and the printed output is <h1>Hello, &lt;script&gt;alert(1)&lt;/script&gt;!</h1>. The angle brackets became &lt; and &gt;, so the browser shows the text instead of running it. Nothing in the component asked for this: parentheses always escape.

Routing has two edge cases that surprise newcomers.

#[tokio::test]
async fn routing_edges() {
    let router = router_with(bundle());
    let res = router.handle(get("/search/")).await;
    println!(
        "/search/ -> {} location={:?}",
        res.status(),
        res.headers().get("location")
    );
    assert_eq!(res.status().as_u16(), 308);
    let res = router
        .handle(
            http::Request::post("/")
                .body(topcoat::router::Body::empty())
                .unwrap(),
        )
        .await;
    println!(
        "POST / -> {} allow={:?}",
        res.status(),
        res.headers().get("allow")
    );
    assert_eq!(res.status().as_u16(), 405);
    let (status, _, _) = send(&router, get("/nope")).await;
    assert_eq!(status, 404);
    let (status, _, body) = send(&router, get("/api/health")).await;
    assert_eq!((status, body.as_str()), (200, "ok"));
}

The test sends four requests to one router. A trailing-slash form of a page, a POST to a page, an unknown path, and the health route each get the answer the router documentation promises. The unknown path returns 404, and the health route returns 200 with the body ok. The first two answers are the ones that look like bugs when you first meet them, so they are worth reading as diagnostics.

Symptom: a request to a page with a trailing slash, or a POST to a page, does not reach your handler:

/search/ -> 308 Permanent Redirect location=Some("/search")
POST / -> 405 Method Not Allowed allow=Some("GET, HEAD")

Cause: the router guide says a trailing slash is part of the path, and a request for the other form is redirected to the declared one by default. Here /search is declared, so /search/ earns a 308 redirect pointing back to it. The same guide says a page serves GET by default. A POST to / matches the path but no route accepts the method, so the router returns 405 with an Allow header.

Fix: link to the exact declared form of each path. For browser-initiated updates, use a shard (Concept C), whose endpoint accepts POST, rather than posting to a page.

Concept B: browser reactivity with signals

A signal is a value that holds state in the browser, created on the server with signal(cx, || initial) and read or changed with .get() and .set(...). A **$(...) expression** is ordinary type-checked Rust that the server evaluates for the initial HTML and that Topcoat also translates to JavaScript. An attribute starting with @ attaches a DOM event handler, which is a function the browser calls when an event such as a click happens on an element (JavaScript: The Definitive Guide covers this under addEventListener). An attribute starting with : is a bind attribute: the server renders its expression's initial value, and the browser updates the attribute whenever a signal read by the expression changes. The point of all this is that a click on "+1" changes one number in the browser, so a server round trip would add delay and load for no benefit.

The home page uses two signals.

#[page("/")]
async fn home(cx: &Cx) -> Result<impl View> {
    let count = signal(cx, || 0usize);
    let open = signal(cx, || false);
    Ok(view! {
        greeting(name: "reader")
        <button @click=$(|_e| count.set(count.get() + 1))>"+1"</button>
        <p>"Clicked " $(count.get()) " times"</p>
        <button @click=$(|_e| open.set(!open.get()))>"What is this?"</button>
        <p :hidden=$(!open.get())>"A server-rendered page."</p>
    })
}

count starts at zero and open at false; the closure passed to signal runs on the server to produce the starting value. Each button's @click takes a closure that receives the event, ignored here as _e, and updates a signal. The text $(count.get()) renders the current count in place. :hidden=$(!open.get()) binds the hidden attribute to the negation of open. The server sent this real HTML for the page, trimmed to the relevant pieces:

<!--::topcoat::signal({&quot;t&quot;:&quot;signal&quot;,&quot;id&quot;:&quot;d093351ad2af29b920eb7843f85fbef8&quot;,&quot;v&quot;:{&quot;t&quot;:&quot;usize&quot;,&quot;bits&quot;:64,&quot;v&quot;:&quot;0&quot;}})-->
<button data-topcoat-on:click="(__local0) => (cx.hydrate({&quot;t&quot;:&quot;Signal&quot;,&quot;id&quot;:&quot;d093351ad2af29b920eb7843f85fbef8&quot;})).set((cx.hydrate({&quot;t&quot;:&quot;Signal&quot;,&quot;id&quot;:&quot;d093351ad2af29b920eb7843f85fbef8&quot;})).get().add((cx.hydrate({&quot;t&quot;:&quot;usize&quot;,&quot;bits&quot;:64,&quot;v&quot;:&quot;1&quot;}))))">+1</button>
<p>Clicked <!--::topcoat::expr::start("(cx.hydrate({&quot;t&quot;:&quot;Signal&quot;,&quot;id&quot;:&quot;d093351ad2af29b920eb7843f85fbef8&quot;})).get()")-->0<!--::topcoat::expr::end--> times</p>
<p hidden="" data-topcoat-bind:hidden="(cx.hydrate({&quot;t&quot;:&quot;Signal&quot;,&quot;id&quot;:&quot;ebac8622a1af2619d508d53e1ec9ba8c&quot;})).get().not()">A server-rendered page.</p>

Read it from top to bottom. The first line declares the counter signal inside an HTML comment: an id, a type of usize with 64 bits, and the value "0". The button carries its closure as JavaScript in a data-topcoat-on:click attribute, where count.get() + 1 became .get().add(...) on the signal. In the paragraph, the literal 0 sits between expr::start and expr::end comments, so the text is already correct before any script runs, and the comments show the browser which text to replace. The toggle paragraph carries hidden="" because !open.get() is true while open is false. Per Jennifer Robbins' HTML5 Pocket Reference, hidden "prevents the element and its descendants from being rendered in the user agent (browser)". A closed toggle is therefore invisible from the first byte, with no script needed.

A test pins these three facts:

#[tokio::test]
async fn signals_render_their_initial_value_on_the_server() {
    let (_, _, html) = send(&router_with(bundle()), get("/")).await;
    // The counter's text is already in the HTML before any JavaScript runs.
    assert!(
        html.contains("-->0<!--::topcoat::expr::end--> times</p>"),
        "{html}"
    );
    // The toggle starts closed, so the server writes the hidden attribute.
    assert!(
        html.contains(r#"<p hidden="" data-topcoat-bind:hidden="#),
        "{html}"
    );
    // The click handler is shipped as JavaScript in a data attribute.
    let handler = html.split("data-topcoat-on:click=\"").nth(1).unwrap();
    println!("click handler starts: {}", &handler[..40]);
}

The first assertion looks for the 0 followed by the closing expression comment, the second for hidden="" followed by the bind attribute, and the third splits the page at the first click attribute and prints its first 40 characters. The printed line is click handler starts: (__local0) => (cx.hydrate({&quot;t&quot;, which is the start of a JavaScript arrow function. All three checks run against the server's response, so the server alone produces them, with no browser involved.

Interactive pages need setup in three places. First, the document head must include topcoat::runtime::script(), which renders the script tag for the browser runtime; the layout above does this. Second, the router needs .assets(AssetBundle::load()...), which serves the bundled files under /_topcoat/assets and resolves their URLs in views. Third, it needs .runtime(), which enables page reruns. The runtime script is an asset, a file Topcoat serves and links by a URL derived from its content. The command topcoat asset bundle creates the asset bundle, the directory of such files. It comes from cargo install topcoat-cli, which installs a single topcoat executable.



  

By default the command writes next to the executable it scanned, here target/debug/assets/. The manifest.toml file lists each asset: an id, the served file name, a SHA-256 hash, and a content_type. Look at the file name: topcoat-a85ca5b0b8314af5.js is the first 16 hex digits of the hash. The server serves it with content-type: text/javascript, content-length: 49018, and cache-control: public, max-age=31536000, immutable. A year-long immutable cache is safe because any change to the file changes its name, and so its URL. The id is a different matter. Bundling the same source on Linux and on macOS gave an identical file name and SHA-256 but different ids, 18345764908960963447 on Linux and 4453574663581270341 on macOS. The asset guide explains why: build settings and generated file paths can affect asset identifiers. That is why it says to deploy the binary and the asset bundle from the same build, and never to reuse an asset bundle from another build.

One rule from the runtime docs applies from here on: values sent back by the browser are user input and must be validated.

A short script drives the running app in headless Chrome (Chrome running without a window, controlled by code) through playwright-core, a browser-automation library, and counts the requests the page makes after it loads.

// Drives the running app in headless Chrome and logs every request after load.
import { chromium } from "playwright-core";
const base = process.env.BASE ?? "http://127.0.0.1:3999";
const browser = await chromium.launch({ executablePath: process.env.CHROME ?? "/usr/bin/google-chrome" });
const page = await browser.newPage();
let log = [];
page.on("request", (r) => log.push(`${r.method()} ${new URL(r.url()).pathname}`));
page.on("response", (r) => { if (r.status() >= 400) console.log("http", r.status(), new URL(r.url()).pathname); });

await page.goto(base + "/", { waitUntil: "networkidle" });
log = [];
await page.click("text=+1");
await page.click("text=+1");
await page.click("text=+1");
console.log("home after 3 clicks:", await page.textContent("main p"));
const sel = "main p:nth-of-type(2)";
const hidden0 = await page.$eval(sel, (e) => e.hasAttribute("hidden"));
await page.click("text=What is this?");
const hidden1 = await page.$eval(sel, (e) => e.hasAttribute("hidden"));
console.log(`toggle: hidden before=${JSON.stringify(hidden0)} after=${JSON.stringify(hidden1)}`);
await page.waitForTimeout(300);
console.log("requests during home clicks:", log.length, JSON.stringify(log));

await page.goto(base + "/search", { waitUntil: "networkidle" });
log = [];
await page.fill("input", "rust");
await page.waitForFunction(() => document.querySelector(".count")?.textContent.startsWith("3"));
console.log("search 'rust':", await page.textContent(".count"), JSON.stringify(await page.$$eval("li", (l) => l.map((x) => x.textContent))));
console.log("requests while typing:", log.length, JSON.stringify(log));
const focused = await page.evaluate(() => document.activeElement?.tagName);
console.log("focus after swap:", focused, "value:", await page.inputValue("input"));
log = [];
await page.fill("input", "");
await page.waitForFunction(() => document.querySelector(".count")?.textContent.startsWith("5"));
log = [];
await page.locator("input").pressSequentially("data", { delay: 150 });
await page.waitForFunction(() => document.querySelector(".count")?.textContent.startsWith("2"));
await page.waitForTimeout(300);
console.log("typed 'data' one key at a time:", await page.textContent(".count"), "requests:", log.length, JSON.stringify(log));
await browser.close();

The script launches Chrome through playwright-core, records every request in log, and empties the log after each page finishes loading, so only requests caused by interaction are counted. This section covers the first half: three clicks on "+1", then a click on the toggle, then a 300-millisecond wait so a late request would still show up. The second half exercises search and appears in Concept C. The home output was:

home after 3 clicks: Clicked 3 times
toggle: hidden before=true after=false
requests during home clicks: 0 []

The counter reached 3, the toggle opened, and the request list is empty, so these interactions never touched the server.

Setup mistakes surface as 500 responses, and the router docs say a panic while handling a request becomes a 500. The tests reproduce the two common ones.

// Each test reproduces a mistake and asserts the symptom you would see.
mod common;
use common::{bundle, get, send, shard_post};
use topcoat::{
    Result,
    context::Cx,
    router::{Router, page},
    runtime::shard,
    view::{View, view},
};
use topcoat_shelf::require_member;

// Pitfall 2: a page uses the runtime but the router never called .runtime().
#[page("/")]
async fn needs_runtime() -> Result<impl View> {
    Ok(view! { <html><head>topcoat::runtime::script()</head><body></body></html> })
}

#[tokio::test]
async fn missing_runtime_setup_is_a_500() {
    let router = Router::builder().page(needs_runtime).build();
    let res = router.handle(get("/")).await;
    println!("without .runtime(): {}", res.status());
    assert_eq!(res.status().as_u16(), 500);
}

// Pitfall 3: .runtime() without the asset bundle. The script URL cannot resolve.
#[tokio::test]
async fn runtime_without_assets_is_a_500() {
    use topcoat::runtime::RouterBuilderRuntimeExt;
    let router = Router::builder().page(needs_runtime).runtime().build();
    let res = router.handle(get("/")).await;
    println!("with .runtime() but no .assets(): {}", res.status());
    assert_eq!(res.status().as_u16(), 500);
    // With the bundle loaded the same page renders.
    use topcoat::asset::RouterBuilderAssetExt;
    let ok = Router::builder()
        .page(needs_runtime)
        .assets(bundle())
        .runtime()
        .build();
    let (status, _, html) = send(&ok, get("/")).await;
    println!("with both: {status} {html}");
    assert_eq!(status, 200);
}

This file lists its imports once, and the leaky-shard test belongs to Concept C. needs_runtime is a minimal page whose head calls script(). The first test builds a router without .runtime(), the second adds .runtime() but no .assets(...), and the end of the second shows the same page returning 200 once both are present. Each failing case produces a different panic message, which tells you which call is missing.

Symptom: the page answers 500 Internal Server Error, and the server log shows:

without .runtime(): 500 Internal Server Error
the browser runtime is not set up on this router; call `.runtime()` on the router builder

Cause: the page asked for the runtime script, but the router builder never enabled the runtime.

Fix: add .runtime() to the builder.

Symptom: after adding .runtime() alone, the page still answers 500:

with .runtime() but no .assets(): 500 Internal Server Error
no asset config registered in this router context; load the asset bundle with `.assets(AssetBundle::load().unwrap())`

Cause: the script tag's URL is an asset URL, and with no asset bundle registered on the router there is nothing to resolve it against.

Fix: add .assets(AssetBundle::load().unwrap()), as router_with does. With both calls in place the same page returns 200 and its head holds <script type="module" src="/_topcoat/assets/topcoat-a85ca5b0b8314af5.js" data-topcoat-usize-bits="64"></script>.

Concept C: shards

A shard is a component, declared with #[shard("/path")], that re-renders on the server when its inputs change. Topcoat re-renders it whenever one of its $(...) arguments changes and swaps the new HTML in place. The shard has its own HTTP endpoint at the declared path, so the path cannot contain dynamic or catch-all parameters. This is the tool for data that lives on the server. A search result list cannot be computed from browser state alone, so it needs a request, but it should not need a whole page reload.

/// Case-insensitive substring match over the shelf.
pub fn filter(query: &str) -> Vec<&'static str> {
    let q = query.trim().to_lowercase();
    BOOKS
        .iter()
        .copied()
        .filter(|title| title.to_lowercase().contains(&q))
        .collect()
}

#[shard("/search/results")]
async fn search_results(query: String) -> Result<impl View> {
    // The browser can send anything that fits a String, so check it here.
    if query.chars().count() > MAX_QUERY_CHARS {
        return Err(bad_request("query longer than 64 characters").into());
    }
    let hits = filter(&query);
    Ok(view! {
        <p class="count">(hits.len()) " match(es)"</p>
        <ul>
            for title in hits {
                <li>(title)</li>
            }
        </ul>
    })
}

#[page("/search")]
async fn search(cx: &Cx) -> Result<impl View> {
    let query = signal(cx, String::new);
    Ok(view! {
        <input :value=$(query.get()) @input=$(|e: Event| query.set(e.target.value))>
        search_results(query: $(query.get()))
    })
}

filter is plain Rust: trim, lowercase, and keep titles that contain the text. The shard search_results takes query: String, rejects anything over 64 characters with bad_request, and renders a count plus a for loop of list items. The page holds the query signal, a text input that binds :value to it, and an @input handler that copies e.target.value into it. The last line calls the shard with query: $(query.get()). That argument expression is what Topcoat watches: when the signal changes, the shard re-renders. Shards are added to the router with .route(search_results), as shown earlier.

The first page load already contains the shard's content, and the runtime docs say it does not need a separate request. The real HTML from GET /search, trimmed, shows how:

<!--::topcoat::shard::start("/search/results", "ELqdwIWvKwgWTT2XcqNpOQ", ["(cx.hydrate({&quot;t&quot;:&quot;Signal&quot;,&quot;id&quot;:&quot;04210c8784af2697304c7237d2c1ca95&quot;})).get()"])--><p class="count">5 match(es)</p><ul><li>The Rust Programming Language</li><li>Rust for Rustaceans</li><li>Zero To Production In Rust</li><li>Designing Data-Intensive Applications</li><li>Database Internals</li></ul><!--::topcoat::shard::end("ELqdwIWvKwgWTT2XcqNpOQ")-->

The shard's output sits between ::topcoat::shard::start(...) and ::topcoat::shard::end(...) comments. The start marker names the endpoint, /search/results, plus a token and the JavaScript for the argument expression. With an empty query, all five books match, so the page shows "5 match(es)" without any extra round trip. These tests cover the inline render, the endpoint, and argument handling.

#[tokio::test]
async fn shard_renders_inline_with_its_endpoint_marker() {
    let (_, _, html) = send(&router_with(bundle()), get("/search")).await;
    assert!(
        html.contains(r#"<!--::topcoat::shard::start("/search/results", "#),
        "{html}"
    );
    assert!(
        html.contains("<p class=\"count\">5 match(es)</p>"),
        "{html}"
    );
}

#[tokio::test]
async fn shard_endpoint_returns_only_the_fragment() {
    let router = router_with(bundle());
    let (status, _, html) = send(
        &router,
        shard_post("/search/results", r#"{"args":["rust"],"signals":{}}"#),
    )
    .await;
    println!("{html}");
    assert_eq!(status, 200);
    assert!(
        !html.contains("<html>"),
        "layout must not wrap a shard re-render"
    );
    assert!(
        html.starts_with("<p class=\"count\">3 match(es)</p>"),
        "{html}"
    );
}

#[tokio::test]
async fn shard_arguments_are_typed_and_validated() {
    let router = router_with(bundle());
    let (status, _, body) = send(
        &router,
        shard_post("/search/results", r#"{"args":[42],"signals":{}}"#),
    )
    .await;
    println!("{status} {body}");
    assert_eq!(status, 400);
    let long =
        Box::leak(format!(r#"{{"args":["{}"],"signals":{{}}}}"#, "x".repeat(65)).into_boxed_str());
    let (status, _, body) = send(&router, shard_post("/search/results", long)).await;
    println!("{status} {body}");
    assert_eq!(status, 400);
    assert!(body.contains("query longer than 64 characters"), "{body}");
}

#[test]
fn filter_is_case_insensitive_and_trims() {
    assert_eq!(
        filter("  DATA "),
        vec![
            "Designing Data-Intensive Applications",
            "Database Internals"
        ]
    );
    assert_eq!(filter("").len(), 5);
}

The endpoint accepts a POST whose JSON body has an args list and a signals object. The rust request returns <p class="count">3 match(es)</p><ul>..., only the fragment, with no <html> and no layout. The 400 answers show two layers of checking. A number where a string belongs fails decoding with a message from serde, Rust's serialization library, and the 65-character string passes decoding but fails the shard's own check. The test prints both:

400 bad request: invalid JSON value: invalid type: integer `42`, expected a string at line 1 column 11 (at `args[0]`)
400 bad request: query longer than 64 characters

The first line even names the position, args[0], so a client bug is easy to trace. Argument types are typed because, as the runtime docs put it, they must belong to the shared vocabulary of expr!, since their values cross between Rust and JavaScript. The length check is yours to write, since the browser can send any string. The last test confirms filter ignores case and surrounding spaces.

The second half of the probe script shows the browser side. Filling the box with rust at once sent one request, and typing data one key at a time sent four.

search 'rust': 3 match(es) ["The Rust Programming Language","Rust for Rustaceans","Zero To Production In Rust"]
requests while typing: 1 ["POST /search/results"]
focus after swap: INPUT value: rust
typed 'data' one key at a time: 2 match(es) requests: 4 ["POST /search/results","POST /search/results","POST /search/results","POST /search/results"]

Each change to the signal triggers a call, so four keystrokes mean four POSTs, and the docs add that a new request aborts any earlier pending one so the latest arguments win. The final result is two matches. Focus stayed in the input because, per the docs, existing elements are updated in place to preserve focus and input state.

Guards are where shards catch people out. The Topcoat context guide recommends "functions, not middlewares": a guard is a plain function that takes the request context and returns a Result. That keeps access checks visible in the code that needs them. The project's guard reads a demo header, and a real app would check a session.

/// A guard written as a plain function on the request context.
/// A real app would check a session; this one reads a demo header.
pub fn require_member(cx: &Cx) -> Result<()> {
    match headers(cx).get("x-member") {
        Some(value) if value == "yes" => Ok(()),
        _ => Err(unauthorized().into()),
    }
}

#[shard("/members/notes")]
async fn member_notes(cx: &Cx) -> Result<impl View> {
    // The page's guard does not run for this endpoint, so repeat it.
    require_member(cx)?;
    Ok(view! { <p class="notes">"Members-only reading notes."</p> })
}

#[page("/members")]
async fn members(cx: &Cx) -> Result<impl View> {
    require_member(cx)?;
    Ok(view! {
        <h1>"Members"</h1>
        member_notes()
    })
}

require_member returns Ok(()) only when the x-member header equals yes; otherwise it returns a 401 error, and the ? operator passes that error straight out of the caller. Both the page members and the shard member_notes call it. The comment in member_notes states the reason: the runtime docs say a shard has its own endpoint, so validate its arguments and authorize access inside the shard, because its page and layout guards do not run for requests to that endpoint. The mistake looks like this in pitfalls.rs, which guards the page and forgets the shard:

// Pitfall 1: guard on the page only. The shard endpoint skips it.
#[shard("/leaky/notes")]
async fn leaky_notes() -> Result<impl View> {
    Ok(view! { <p>"Members-only reading notes."</p> })
}

#[page("/leaky")]
async fn leaky(cx: &Cx) -> Result<impl View> {
    require_member(cx)?;
    Ok(view! { leaky_notes() })
}

#[tokio::test]
async fn page_guard_does_not_protect_its_shard() {
    let router = Router::builder().page(leaky).route(leaky_notes).build();
    let (page_status, _, _) = send(&router, get("/leaky")).await;
    let (shard_status, _, html) = send(
        &router,
        shard_post("/leaky/notes", r#"{"args":[],"signals":{}}"#),
    )
    .await;
    println!("GET /leaky -> {page_status}; POST /leaky/notes -> {shard_status} {html}");
    assert_eq!(page_status, 401);
    assert_eq!(shard_status, 200);
}

The test builds a tiny router with a guarded page and an unguarded shard, then requests both. The page answers 401, as designed. The shard endpoint answers 200 with the private text to any caller who POSTs to its URL directly. The page never renders for an unauthorized visitor, yet the shard's own path is open. The path is printed in the shard::start marker of every page that includes the shard, so one authorized visit, a shared HTML snippet, or a guess is enough to find it. The request also needs nothing special: Topcoat's request code defines an x-topcoat-identity header that the browser runtime sends, but the endpoint answered these test requests, and the curl calls against the running app, without it.

Symptom:

GET /leaky -> 401; POST /leaky/notes -> 200 <p>Members-only reading notes.</p>

Cause: a shard's endpoint runs without its page or layouts, so a guard placed only on the page does not cover it.

Fix: call the guard inside the shard, as member_notes does, and test the endpoint directly rather than through the page:

#[tokio::test]
async fn guarded_shard_rejects_direct_calls() {
    let router = router_with(bundle());
    let (status, _, _) = send(&router, get("/members")).await;
    assert_eq!(status, 401);
    let (status, _, _) = send(
        &router,
        shard_post("/members/notes", r#"{"args":[],"signals":{}}"#),
    )
    .await;
    assert_eq!(status, 401);
    let req = http::Request::post("/members/notes")
        .header("content-type", "application/json")
        .header("x-member", "yes")
        .body(topcoat::router::Body::from(r#"{"args":[],"signals":{}}"#))
        .unwrap();
    let (status, _, html) = send(&router, req).await;
    assert_eq!(status, 200);
    assert!(html.contains("Members-only"), "{html}");
}

The test makes three requests against the real router. The page without credentials gets 401, which only proves the page guard. The second request is the one that matters: a direct POST to /members/notes, the same call the leaky version answered with 200, now gets 401 because member_notes runs require_member itself. The third request adds x-member: yes and gets the notes. The running app gives the same answers over HTTP:

$ curl -si -X POST localhost:3999/members/notes -H "content-type: application/json" -d '{"args":[],"signals":{}}'
HTTP/1.1 401 Unauthorized
content-type: text/plain; charset=utf-8
content-length: 12

unauthorized
$ curl -s -X POST localhost:3999/members/notes -H "x-member: yes" -H "content-type: application/json" -d '{"args":[],"signals":{}}'
<p class="notes">Members-only reading notes.</p>

Without the header, the endpoint now answers with the same plain-text 401 Unauthorized that the page gives, a 12-byte body reading unauthorized. With x-member: yes, it returns only the notes paragraph, again with no layout around it. These two calls are worth keeping as a smoke test for any guarded fragment, because a page-level check passing tells you nothing about the shard's endpoint.

Putting it together

The opening problem asked for a page rendered in Rust, interactions without requests, and one live fragment, all without JavaScript or WebAssembly. One Rust file now serves them. root_layout and home produce server-rendered pages, the counter and toggle run in the browser from translated $(...) expressions, and search_results re-renders on the server for the live search. member_notes is the guarded fragment, and health is the plain route. There is no JSON API layer or TypeScript project, and the only JavaScript on the wire is the 49,018-byte runtime plus the translated expressions in HTML attributes.

To run it, install the CLI, create the asset bundle, test, and serve:

cargo install topcoat-cli --version '=0.10.0' --locked
topcoat asset bundle
cargo test
PORT=3999 cargo run
curl -s -X POST localhost:3999/search/results -H "content-type: application/json" -d '{"args":["data"],"signals":{}}'
<p class="count">2 match(es)</p><ul><li>Designing Data-Intensive Applications</li><li>Database Internals</li></ul>

The first command installs the topcoat executable at the same version as the library, so the asset bundle matches the runtime the app expects. The curl call is the request the browser runtime would send after someone typed data, and its answer is the bare fragment with two matches. cargo test runs 12 tests, 9 in http.rs and 3 in pitfalls.rs. If you skip topcoat asset bundle, 8 of them fail with the message from the bundle() helper, and only the 4 tests that never load the asset bundle pass:



  

cargo run listens on 127.0.0.1:3000 unless PORT says otherwise. For daily work, the getting-started guide describes topcoat dev, which builds, starts, and rebuilds on change.

One shell gotcha: in zsh, leaving the quotes off, as in --version =0.10.0, fails with zsh: 0.10.0 not found. The shell treats a word starting with = as a command-path expansion, so quote it as '=0.10.0'. With the quotes, the install succeeds and topcoat --version prints topcoat 0.10.0.

Check yourself

  1. Why does the counter's HTML already say 0 before any JavaScript runs? Answer: The server evaluates each $(...) expression for the initial render and writes the value between expr::start and expr::end comments. The browser later re-runs the translated expression when the signal changes.
  2. A link points to /search/ and the browser lands on /search. What happened, and what does a POST to / return? Answer: The trailing slash is part of the path, so the router redirected to the declared form with a 308 and location: /search. A page serves GET by default, so a POST to / returns 405 with allow: GET, HEAD.
  3. A page calls require_member(cx)? and renders a shard. Is the shard protected? Answer: No. A shard's endpoint runs without its page or layouts, so a direct POST returns 200 unless the shard calls the guard itself.
  4. Which router calls does a page that uses topcoat::runtime::script() need, and what does each missing call produce? Answer: It needs .runtime() and .assets(...). Without .runtime() the page returns 500 with the panic "the browser runtime is not set up on this router". With .runtime() but no .assets(...) it returns 500 with "no asset config registered in this router context".
  5. Typing data one key at a time sent four POSTs, and filling rust at once sent one. Why? Answer: Each change to the signal behind the shard's $(...) argument triggers a re-render request. Four keystrokes changed the signal four times, while fill changed it once. A newer request aborts an earlier pending one, so the latest arguments win.

Sources