Skip to main content

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('&', "&amp;")
     .replace('<', "&lt;")
     .replace('>', "&gt;")
     .replace('"', "&quot;")
     .replace('\'', "&#x27;")
}

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

Email

Send transactional emails

Build docs developers (and LLMs) love