I tried building a web app in Rust last year. Not just an API — a real web app with login pages, search, and real-time updates. I spent two weeks wiring up Axum on the backend, React on the frontend, a REST API layer to connect them, CORS middleware because of course they live on different ports, and a build pipeline that involved Node.js, Webpack, and more configuration files than my entire Rust project.
It worked. But it was exhausting. Every feature required touching at least three files across two languages. And somewhere around week three, I asked myself: why can’t I just write the whole thing in Rust?
Turns out the tokio-rs team had the same question. Their answer is Topcoat — a new full-stack Rust framework that lets you build server-rendered web apps with client-side reactivity, all in a single Rust codebase. No React, no Vue, no separate API layer, no WASM bundles. Just Rust, from your database query to your button click handler.
I’ve been playing with it for a few days, and I think it’s one of the most exciting developments in the Rust web ecosystem this year. Let me walk you through building your first Topcoat app from scratch — you’ll have a working full-stack Rust application running in about 15 minutes.

Before We Start: What Makes Topcoat Different
Topcoat is built by the tokio-rs team — the same people behind Tokio, the async runtime that powers most of the Rust web ecosystem. It’s a batteries-included framework that prioritizes simplicity and productivity. Think of it as what you’d get if someone built a Rails-like experience for Rust, but with the type safety and performance you’d expect from the language. If you’ve been exploring Rust through projects like building and running the Linux 0.11 rewrite in Rust, you already know what the language can do at the systems level. Topcoat proves it can do full-stack web too.
The core idea is straightforward: you write all your web app logic in Rust, on the server. HTML templates, database queries, authentication, form handling, and even client-side interactivity — all in one language, all in one codebase. But Topcoat has two clever tricks up its sleeve that make this actually practical:
- Signals — reactive values that can update the DOM without a server round-trip. A
$(...)expression in your template is real, type-checked Rust that Topcoat translates to JavaScript automatically. The initial render happens on the server, but updates run instantly in the browser. - Shards — components that re-render on the server when their arguments change. Think LiveView or Hotwire, but with Rust’s async performance and compile-time safety.
No WASM, no build step for the frontend, no API contract to maintain. Just Rust functions that return HTML. This is a fundamentally different approach from runtimes like Ant, the 9MB JavaScript runtime that cold-starts in 5ms — while Ant optimizes the JS runtime itself, Topcoat eliminates the need for a separate frontend runtime entirely.
Prerequisites
Before we start, make sure you have:
- Rust toolchain installed (1.80+). If you don’t have it:
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh - Cargo — comes with Rust
- A terminal and a text editor
That’s it. No Node.js, no npm, no WASM toolchain, no Docker. Just Rust.
Step 1: Create a New Project
Start with a fresh Cargo binary:
cargo new topcoat-hello-world
cd topcoat-hello-world
This creates a minimal Rust project with src/main.rs and Cargo.toml.
Step 2: Add Dependencies
Add Topcoat and Tokio as dependencies:
cargo add topcoat
cargo add tokio --features rt-multi-thread,macros
The first build takes a moment — Cargo pulls in Axum (for the HTTP server), tower (middleware), and the reactive runtime — but subsequent builds are fast thanks to incremental compilation.
Step 3: Write Your First Page
Replace src/main.rs with this:
use topcoat::{
Result,
router::{Router, RouterBuilderDiscoverExt, page},
view::{component, view},
};
#[tokio::main]
async fn main() {
topcoat::start(Router::builder().discover().build()).await.unwrap();
}
#[page("/")]
async fn home() -> Result {
view! {
<!DOCTYPE html>
<html>
<head>
<title>"Hello world"</title>
topcoat::dev::script()
</head>
<body>
hello(name: "World")
</body>
</html>
}
}
#[component]
async fn hello(name: &str) -> Result {
view! {
<h1>"Hello, " (name) "!"</h1>
}
}
Here’s what each piece does:
#[page("/")]— registers this function as a route handler for the root URL. Routes are async functions that return HTML.view!— Topcoat’s templating macro. It looks like writing HTML, but every tag and expression is compiled Rust, type-checked and safe.topcoat::dev::script()— injects the development client for hot reload. Your page auto-refreshes when you save changes.#[component]— creates a reusable UI component. Thehellofunction takes a parameter and returns HTML.(name)inside theview!macro — inserts the variable value into the HTML output.
You can actually run this right now: cargo run starts the server on http://127.0.0.1:3000. No CLI needed for basic serving.
Step 4: Install the CLI and Start Dev Server
For day-to-day development, install the Topcoat CLI. It enables hot reloading, asset bundling, and source formatting:
cargo install topcoat-cli
This installs a topcoat executable. Once it’s ready, start the dev server:
topcoat dev
Open http://127.0.0.1:3000 and you’ll see Hello, World!. The dev server watches your source directory and rebuilds on every save — no manual restarts, no file watcher configuration.
To change the bind address: HOST=0.0.0.0 PORT=8080 topcoat dev
Step 5: Add Client-Side Interactivity
Here’s where Topcoat breaks from the traditional server-rendered mold. Let’s add a toggle button that responds instantly in the browser — no network request needed:
#[component]
async fn toggle_demo() -> Result {
view! {
signal open = false;
<button @click=$(|_e| open.set(!open.get()))>
"What is Topcoat?"
</button>
<p :hidden=$(!open.get())>
"A fullstack Rust framework."
</p>
}
}
Add toggle_demo() to your home page body and rebuild. Here’s the breakdown:
signal open = falsedeclares a reactive signal initialized tofalse@click=$(|_e| open.set(!open.get()))is an event handler written in Rust. Click the button, the signal toggles, the DOM updates — all client-side:hidden=$(!open.get())binds thehiddenattribute to the signal’s inverse value. Whenopenisfalse, the paragraph stays hidden
The mind-blowing part: that $(...) expression is real Rust code. The compiler type-checks it at build time. If you pass a wrong argument type, the build fails — not at runtime in a browser console. This alone makes me sleep better compared to debugging JavaScript at 2 AM. It’s the same principle behind tools like Destructive Command Guard for AI coding tools — compile-time guardrails beat runtime panic every time.
Step 6: Server-Side Updates with Shards
Some things need the server — search results, database lookups, authentication. Topcoat handles this with shards, which are components that re-render on the server when their signal arguments change:
#[component]
async fn search_page() -> Result {
view! {
signal query = String::new();
<input @input=$(|e: Event| query.set(e.target.value)) />
search_results(query: $(query.get()))
}
}
#[shard]
async fn search_results(cx: &Cx, query: String) -> Result {
view! {
<ul>
for product in search_products(cx, &query).await? {
<li>(product.name)</li>
}
</ul>
}
}
The #[shard] attribute marks the component for server-side re-rendering. As the user types, the signal updates, which triggers a server request that re-renders only the search results and swaps the HTML. No page reload, no client framework, no API contract to maintain.
This pattern reminds me of Rails Hotwire or Phoenix LiveView — but with Rust’s type system and async performance under the hood.
Step 7: Module-Based Routing
For larger apps, Topcoat can auto-discover your route tree from your module structure:
src/
|-- app.rs -> / (root layout)
|-- app/
|-- about.rs -> /about
|-- posts.rs -> /posts
|-- posts/
| `-- id.rs -> /posts/{post_id}
`-- api/
`-- health.rs -> GET /api/health
No route registry file. No dynamic dispatch. The module structure is your route table. Layout modules prefixed with _ (like _marketing.rs) wrap sub-routes without adding a URL segment.
The bundler is equally slick — declare assets with asset!("./ferris.png") and Topcoat serves them with content-hashed URLs for aggressive caching:
const FERRIS: Asset = asset!("./ferris.png");
view! { <img src=(FERRIS) /> }
There’s also built-in Tailwind support. Enable the tailwind feature and add one line to your template — no Node.js or PostCSS setup needed:
<link rel="stylesheet" href=(topcoat::tailwind::stylesheet!()) />
Where Topcoat Fits — and Where It’s Going
Topcoat isn’t competing with Axum or Actix — it actually uses Axum under the hood. Instead, it’s competing with the combination of a backend framework, a frontend framework, an API layer, and a build pipeline. For solo developers or small teams shipping full-stack apps in Rust, that combination is exactly the problem worth solving.
It’s still early — the README explicitly warns about breaking changes, and the crate is on version 0.x. But 748 commits from the tokio-rs team is a strong signal. The documentation on docs.rs covers sessions, cookies, app context, memoization, and the full expression language. The team is active on the Tokio Discord.
If you’re evaluating full-stack Rust and wondering if there’s a way to avoid maintaining two codebases, give Topcoat a try. Create a project, install the CLI, and build a page with a signal. Just like companies are ditching paid AI APIs for self-hosted open-source models, the best way to evaluate a new framework is to actually run it yourself. Even if you don’t ship production with it, the ideas it brings — server-driven UI with compile-time safety — are worth understanding.
Because honestly? The future of web development might not be another JavaScript framework. It might be no JavaScript at all.