Overview
TrailBase’s WASM runtime enables server-side rendering (SSR) of HTML pages. You can generate dynamic content, use template engines, and serve complete web pages directly from your WASM components.Basic HTML Response
use trailbase_wasm::http::{Html, HttpRoute, Request, routing};
use trailbase_wasm::{Guest, export};
struct MyPages;
impl Guest for MyPages {
fn http_handlers() -> Vec<HttpRoute> {
vec![
routing::get("/page", render_page),
routing::get("/users/{id}", user_profile),
]
}
}
async fn render_page(_req: Request) -> Html<String> {
let html = r#"
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>My Page</title>
<style>
body { font-family: system-ui; margin: 2rem; }
h1 { color: #333; }
</style>
</head>
<body>
<h1>Welcome to TrailBase</h1>
<p>This page is rendered by a WASM component!</p>
</body>
</html>
"#;
Html(html.to_string())
}
export!(MyPages);
Dynamic Content from Database
Query the database and render results:use trailbase_wasm::db::{query, Value};
use trailbase_wasm::http::{Html, HttpError, Request, StatusCode};
async fn user_profile(req: Request) -> Result<Html<String>, HttpError> {
let user_id = req.path_param("id")
.ok_or_else(|| HttpError::status(StatusCode::BAD_REQUEST))?;
let rows = query(
"SELECT name, email, bio FROM users WHERE id = $1",
[Value::Text(user_id.to_string())]
).await.map_err(|err| {
HttpError::message(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())
})?;
if rows.is_empty() {
return Err(HttpError::status(StatusCode::NOT_FOUND));
}
let row = &rows[0];
let name = row[0].as_text().unwrap_or("Unknown");
let email = row[1].as_text().unwrap_or("");
let bio = row[2].as_text().unwrap_or("No bio");
let html = format!(r#"
<!DOCTYPE html>
<html>
<head>
<title>{name}'s Profile</title>
<style>
body {{ font-family: system-ui; max-width: 800px; margin: 2rem auto; }}
.profile {{ background: #f5f5f5; padding: 2rem; border-radius: 8px; }}
h1 {{ margin-top: 0; }}
.email {{ color: #666; }}
.bio {{ margin-top: 1rem; line-height: 1.6; }}
</style>
</head>
<body>
<div class="profile">
<h1>{name}</h1>
<p class="email">{email}</p>
<div class="bio">{bio}</div>
</div>
</body>
</html>
"#);
Ok(Html(html))
}
Templating
Manual String Templating
fn render_template(title: &str, items: &[String]) -> String {
let items_html: String = items
.iter()
.map(|item| format!("<li>{}</li>", escape_html(item)))
.collect::<Vec<_>>()
.join("\n");
format!(r#"
<!DOCTYPE html>
<html>
<head>
<title>{title}</title>
</head>
<body>
<h1>{title}</h1>
<ul>
{items_html}
</ul>
</body>
</html>
"#)
}
fn escape_html(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
.replace('\'', "'")
}
async fn list_page(_req: Request) -> Html<String> {
let rows = query("SELECT name FROM items", []).await.unwrap();
let items: Vec<String> = rows
.iter()
.filter_map(|row| row[0].as_text().map(String::from))
.collect();
Html(render_template("My Items", &items))
}
Component-Based Rendering
struct Page {
title: String,
content: String,
}
impl Page {
fn render(&self) -> String {
format!(r#"
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{}</title>
{}
</head>
<body>
{}
{}
</body>
</html>
"#,
self.title,
self.styles(),
self.header(),
self.content
)
}
fn header(&self) -> String {
format!(r#"
<header>
<nav>
<a href="/">Home</a>
<a href="/about">About</a>
<a href="/contact">Contact</a>
</nav>
</header>
"#)
}
fn styles(&self) -> &str {
r#"
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: system-ui; }
header { background: #333; color: white; padding: 1rem; }
nav a { color: white; margin-right: 1rem; text-decoration: none; }
main { max-width: 1200px; margin: 2rem auto; padding: 0 1rem; }
</style>
"#
}
}
async fn about_page(_req: Request) -> Html<String> {
let page = Page {
title: "About Us".to_string(),
content: r#"
<main>
<h1>About Us</h1>
<p>We build great software with TrailBase and WASM.</p>
</main>
"#.to_string(),
};
Html(page.render())
}
Lists and Tables
Render database results as HTML tables:use trailbase_wasm::db::{query, Value};
async fn users_table(_req: Request) -> Result<Html<String>, HttpError> {
let rows = query(
"SELECT id, name, email, created FROM users ORDER BY created DESC LIMIT 50",
[]
).await?;
let table_rows: String = rows
.iter()
.map(|row| {
let id = row[0].as_integer().unwrap_or(0);
let name = escape_html(row[1].as_text().unwrap_or("N/A"));
let email = escape_html(row[2].as_text().unwrap_or("N/A"));
let created = row[3].as_text().unwrap_or("Unknown");
format!(r#"
<tr>
<td>{id}</td>
<td><a href="/users/{id}">{name}</a></td>
<td>{email}</td>
<td>{created}</td>
</tr>
"#)
})
.collect::<Vec<_>>()
.join("\n");
let html = format!(r#"
<!DOCTYPE html>
<html>
<head>
<title>Users</title>
<style>
body {{ font-family: system-ui; margin: 2rem; }}
table {{ width: 100%; border-collapse: collapse; }}
th, td {{ padding: 0.75rem; text-align: left; border-bottom: 1px solid #ddd; }}
th {{ background: #f5f5f5; font-weight: 600; }}
tr:hover {{ background: #f9f9f9; }}
a {{ color: #0066cc; text-decoration: none; }}
a:hover {{ text-decoration: underline; }}
</style>
</head>
<body>
<h1>Users</h1>
<table>
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>Email</th>
<th>Created</th>
</tr>
</thead>
<tbody>
{table_rows}
</tbody>
</table>
</body>
</html>
"#);
Ok(Html(html))
}
Forms and POST Handling
Handle form submissions:use trailbase_wasm::http::{Request, Redirect, Html, StatusCode};
// Display form
async fn contact_form(_req: Request) -> Html<String> {
Html(r#"
<!DOCTYPE html>
<html>
<head>
<title>Contact Us</title>
<style>
body { font-family: system-ui; max-width: 600px; margin: 2rem auto; }
form { display: flex; flex-direction: column; gap: 1rem; }
label { font-weight: 600; }
input, textarea { padding: 0.5rem; border: 1px solid #ddd; border-radius: 4px; }
button { padding: 0.75rem; background: #0066cc; color: white; border: none;
border-radius: 4px; cursor: pointer; font-size: 1rem; }
button:hover { background: #0052a3; }
</style>
</head>
<body>
<h1>Contact Us</h1>
<form method="POST" action="/contact/submit">
<div>
<label for="name">Name</label>
<input type="text" id="name" name="name" required>
</div>
<div>
<label for="email">Email</label>
<input type="email" id="email" name="email" required>
</div>
<div>
<label for="message">Message</label>
<textarea id="message" name="message" rows="5" required></textarea>
</div>
<button type="submit">Send Message</button>
</form>
</body>
</html>
"#.to_string())
}
// Handle form submission
async fn handle_contact_form(mut req: Request) -> Result<Redirect, HttpError> {
// Parse form data
let body = req.body().text().await
.map_err(|_| HttpError::status(StatusCode::BAD_REQUEST))?;
let params: std::collections::HashMap<String, String> =
url::form_urlencoded::parse(body.as_bytes())
.map(|(k, v)| (k.into_owned(), v.into_owned()))
.collect();
let name = params.get("name")
.ok_or_else(|| HttpError::status(StatusCode::BAD_REQUEST))?;
let email = params.get("email")
.ok_or_else(|| HttpError::status(StatusCode::BAD_REQUEST))?;
let message = params.get("message")
.ok_or_else(|| HttpError::status(StatusCode::BAD_REQUEST))?;
// Save to database
execute(
"INSERT INTO contacts (name, email, message, created) VALUES ($1, $2, $3, datetime('now'))",
[
Value::Text(name.clone()),
Value::Text(email.clone()),
Value::Text(message.clone()),
]
).await?;
// Redirect to thank you page
Ok(Redirect::to("/contact/thank-you"))
}
Pagination
async fn paginated_list(req: Request) -> Result<Html<String>, HttpError> {
let page: i64 = req.query_param("page")
.and_then(|p| p.parse().ok())
.unwrap_or(1);
let per_page = 20i64;
let offset = (page - 1) * per_page;
// Get total count
let count_rows = query("SELECT COUNT(*) FROM posts", []).await?;
let total: i64 = count_rows[0][0].as_integer().unwrap_or(0);
let total_pages = (total + per_page - 1) / per_page;
// Get page of results
let rows = query(
"SELECT id, title, created FROM posts ORDER BY created DESC LIMIT $1 OFFSET $2",
[Value::Integer(per_page), Value::Integer(offset)]
).await?;
let posts_html: String = rows
.iter()
.map(|row| {
let id = row[0].as_integer().unwrap_or(0);
let title = escape_html(row[1].as_text().unwrap_or("Untitled"));
let created = row[2].as_text().unwrap_or("Unknown");
format!("<li><a href='/posts/{id}'>{title}</a> <span>({created})</span></li>")
})
.collect::<Vec<_>>()
.join("\n");
// Generate pagination links
let pagination = (1..=total_pages)
.map(|p| {
if p == page {
format!("<span class='current'>{p}</span>")
} else {
format!("<a href='?page={p}'>{p}</a>")
}
})
.collect::<Vec<_>>()
.join(" ");
let html = format!(r#"
<!DOCTYPE html>
<html>
<head>
<title>Posts - Page {page}</title>
<style>
body {{ font-family: system-ui; max-width: 800px; margin: 2rem auto; }}
ul {{ list-style: none; padding: 0; }}
li {{ padding: 0.75rem; border-bottom: 1px solid #eee; }}
.pagination {{ margin-top: 2rem; text-align: center; }}
.pagination a, .pagination span {{ padding: 0.5rem 0.75rem; margin: 0 0.25rem;
text-decoration: none; color: #0066cc; }}
.pagination .current {{ background: #0066cc; color: white; border-radius: 4px; }}
</style>
</head>
<body>
<h1>Posts</h1>
<ul>{posts_html}</ul>
<div class="pagination">{pagination}</div>
</body>
</html>
"#);
Ok(Html(html))
}
Error Pages
fn error_page(status: StatusCode, message: &str) -> Html<String> {
let title = match status {
StatusCode::NOT_FOUND => "404 - Not Found",
StatusCode::INTERNAL_SERVER_ERROR => "500 - Server Error",
StatusCode::FORBIDDEN => "403 - Forbidden",
StatusCode::UNAUTHORIZED => "401 - Unauthorized",
_ => "Error",
};
Html(format!(r#"
<!DOCTYPE html>
<html>
<head>
<title>{title}</title>
<style>
body {{ font-family: system-ui; display: flex; align-items: center;
justify-content: center; height: 100vh; margin: 0;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); }}
.error {{ background: white; padding: 3rem; border-radius: 12px;
box-shadow: 0 20px 60px rgba(0,0,0,0.3); text-align: center; }}
h1 {{ margin: 0; color: #333; font-size: 3rem; }}
p {{ color: #666; margin-top: 1rem; }}
a {{ color: #667eea; text-decoration: none; font-weight: 600; }}
</style>
</head>
<body>
<div class="error">
<h1>{}</h1>
<p>{}</p>
<p><a href="/">← Go Home</a></p>
</div>
</body>
</html>
"#, status.as_u16(), message))
}
async fn not_found_handler(_req: Request) -> Html<String> {
error_page(StatusCode::NOT_FOUND, "The page you're looking for doesn't exist.")
}
Integrating with Frontend Frameworks
Serving SPA with API
// Serve static React/Vue/Svelte app
async fn spa_handler(req: Request) -> Result<Html<String>, HttpError> {
// Read the built index.html from filesystem
let html = std::fs::read_to_string("/app/dist/index.html")
.map_err(|_| HttpError::status(StatusCode::INTERNAL_SERVER_ERROR))?;
Ok(Html(html))
}
// Provide API endpoints for the SPA
impl Guest for MyApp {
fn http_handlers() -> Vec<HttpRoute> {
vec![
// API routes
routing::get("/api/data", api_get_data),
routing::post("/api/data", api_post_data),
// Catch-all for SPA routing
routing::get("/*", spa_handler),
]
}
}
Performance Tips
Caching: Consider implementing response caching for frequently accessed pages:
use trailbase_wasm::kv;
async fn cached_page(req: Request) -> Result<Html<String>, HttpError> {
let cache_key = format!("page:{}", req.url().path());
// Check cache
if let Some(cached) = kv::get(&cache_key) {
if let Ok(html) = String::from_utf8(cached) {
return Ok(Html(html));
}
}
// Generate page
let html = generate_expensive_page().await?;
// Cache for 5 minutes
kv::set(&cache_key, html.as_bytes().to_vec());
Ok(Html(html))
}
Next Steps
Custom Endpoints
Build JSON APIs
Vector Search
Add semantic search
Jobs Scheduler
Background processing
Send transactional emails