A boilerplate + thin binding library for building Chrome extensions with Leptos and Rust/WASM. Inspired by WXT's architecture philosophy but staying true to Rust's transparency principles.
rxt is NOT a framework. It's a:
- 📐 Project template with standard Cargo workspace structure
- 🔌 1:1 Chrome API bindings via
wasm-bindgen(no opinionated wrappers) - 🔧 Just recipes to orchestrate existing best-in-class tools
- 🚫 Zero magic - no hidden CLI, no code generation, no black boxes
| Feature | rxt | Traditional Frameworks |
|---|---|---|
| Chrome API updates | Add one line in shared/chrome.rs |
Wait for maintainer |
| Build transparency | Plain Justfile + Trunk + cargo |
Custom CLI black box |
| Type safety | Serde-based message protocol | Runtime string matching |
| Ecosystem | Standard Leptos + full crate ecosystem | Framework-specific plugins |
| Stability | Depends only on stable Rust tools | Framework churn risk |
my-extension/
├── Cargo.toml # Workspace definition
├── Justfile # Build orchestration (replaces custom CLI)
├── manifest.json # Native Chrome Manifest V3 (hand-written)
├── assets/
│ ├── background-loader.js # Minimal WASM bootstrap for service worker
│ ├── content-loader.js # Minimal WASM bootstrap for content script
│ └── icons/ # Extension icons
│
├── shared/ # Thin Chrome API bindings + shared types
│ ├── Cargo.toml
│ └── src/
│ ├── lib.rs
│ ├── chrome.rs # 1:1 Chrome API extern bindings
│ └── protocol.rs # Typed message enums for cross-context communication
│
├── popup/ # Leptos CSR app for extension popup
│ ├── index.html # Trunk entry point
│ ├── Cargo.toml
│ └── src/main.rs
│
├── background/ # Service worker (no DOM access)
│ ├── Cargo.toml
│ └── src/main.rs
│
└── content/ # Injected script with Shadow DOM UI
├── Cargo.toml
└── src/lib.rs
# Add WASM target
rustup target add wasm32-unknown-unknown
# Install build tools
cargo install trunk wasm-bindgen-cli cargo-watch
# Optional: formatter and linter tools
cargo install taplo-cli cargo-machete
rustup component add rustfmt clippy --toolchain nightly# One-shot release build
just
# Development mode with auto-rebuild
just watchOutput goes to dist/ - load it as an unpacked extension in Chrome.
Pure wasm-bindgen extern blocks that map 1:1 to Chrome APIs:
#[wasm_bindgen]
extern "C" {
pub type Chrome;
#[wasm_bindgen(js_name = chrome)]
pub static CHROME: Chrome;
#[wasm_bindgen(method, getter, js_name = runtime)]
pub fn runtime(this: &Chrome) -> Runtime;
// ... more APIs
}When Chrome adds new APIs: Just add another extern block. No framework update needed.
Define message contracts once, use everywhere:
#[derive(Serialize, Deserialize)]
pub enum Message {
GetUserData,
SaveSettings { theme: String },
}
#[derive(Serialize, Deserialize)]
pub enum Response {
UserData { name: String },
Saved,
}Send from popup/content → background:
let response: Response = send_msg(Message::GetUserData).await?;No custom CLI. Just composing battle-tested tools:
| Component | Tool | Why |
|---|---|---|
| Popup UI | trunk |
HTML/CSS/Asset bundling + HMR |
| Background worker | cargo + wasm-bindgen --target web |
ES module for service worker |
| Content script | cargo + wasm-bindgen --target no-modules |
Self-contained bundle for injection |
Minimal JS glue (~5 lines each) to bootstrap WASM:
Background (assets/background-loader.js):
import init from '../background/background.js';
init(); // Load WASM and call #[wasm_bindgen(start)]Content (assets/content-loader.js):
const content = await import(chrome.runtime.getURL('content/content.js'));
await content.default(chrome.runtime.getURL('content/content_bg.wasm'));
content.start_content_script(); // Call exported Rust functionEdit shared/src/chrome.rs:
// Add tabs API
#[wasm_bindgen(method, getter, js_name = tabs)]
pub fn tabs(this: &Chrome) -> Tabs;
pub type Tabs;
#[wasm_bindgen(method)]
pub fn query(this: &Tabs, query_info: &JsValue) -> Promise;Use immediately in any crate:
use shared::chrome::CHROME;
let tabs_promise = CHROME.tabs().query(&query_obj);Add variants to shared/src/protocol.rs:
pub enum Message {
GetUserData,
FetchUrl(String), // New!
}
pub enum Response {
UserData { name: String },
FetchedData(String), // New!
}Handle in background/src/main.rs:
Message::FetchUrl(url) => {
let data = fetch_external(&url).await?;
Response::FetchedData(data)
}Use inline styles or inject CSS into shadow DOM:
let style = document.create_element("style")?;
style.set_inner_html(".my-widget { color: red; }");
shadow_root.append_child(&style)?;- ✅ Type safety at compile time (not runtime)
- ✅ No Node.js/npm dependency hell
- ✅ Smaller bundle sizes (WASM is compact)
- ❌ Less mature ecosystem for Chrome extension dev
- ✅ Native performance (no virtual DOM overhead)
- ✅ Better long-term stability (fewer breaking changes)
- ❌ No built-in React ecosystem integrations
- ✅ Eliminates entire classes of runtime errors
- ✅ Refactoring confidence with strong types
- ❌ Slightly more complex build setup
Install in popup/:
cd popup
npm init -y
npm install -D tailwindcss
npx tailwindcss initConfigure Trunk.toml:
[[hooks]]
stage = "pre_build"
command = "npx"
command_arguments = ["tailwindcss", "-i", "./input.css", "-o", "./output.css"]-
Create
options/crate (copypopup/structure) -
Add to workspace in
Cargo.toml -
Add build step in
Justfile:build-options: trunk build options/index.html --dist dist/options --release
-
Reference in
manifest.json:"options_page": "options/index.html"
- Chrome Extension Docs
- Leptos Book
- wasm-bindgen Guide
- WXT Architecture (inspiration)
This is a template project. Fork it and adapt to your needs! If you add useful Chrome API bindings to shared/chrome.rs, consider sharing them back.
MIT OR Apache-2.0 (your choice)
Philosophy: Tools should empower developers, not abstract them away from the platform. rxt gives you Rust's safety + Chrome's full API surface with zero magic in between.