Build a Fullstack SSR Web App with Rust + Maud

Essay - Published: 2026.04.01 | 5 min read (1,462 words)
build | create | rust

DISCLOSURE: If you buy through affiliate links, I may earn a small commission. (disclosures)

In this post we'll be building a fullstack web app with server-side rendered (SSR) HTML using Rust and Maud. This post continues our series on building webapps with Rust:

Why SSR HTML? Because it's simple and it paves the way towards hypermedia-based apps. A lot of people reach directly for heavy frontend frameworks like React but the vast majority of CRUD apps can be rendered simply and efficiently with hypermedia. So here we're going to show how to do that and how easy it is to convert from JSON apis to HTML endpoints.

What we're building

In this post we'll be updating our web api to return HTML instead of json. This will effectively give us a fullstack app with server-side rendered HTML.

We'll be building on our simple CRUD todo list app we've been building in the rest of the series. As such we'll focus on the new server-side rendered code and skim over the rest - for more details read Build a Single-File Rust Web API with SQLite.

Tech Stack:

  • Maud - A DSL for server-side rendered HTML. I'm a fan of HTML DSLs, using them heavily in my time with F# and even building my own for C#. I think they offer great ergonomics, type safety, and lots of flexibility for dynamically generating frontends from your server.
  • Axum - The most popular web framework in Rust.

How it's built

Data Models

The data models haven't changed from our previous implementations.

  • Todo - Domain model for a Todo
  • CreateTodo - Dto for a create command from create route
  • TodoRow - Data model for a Todo
#[derive(Clone, LightClone)]
struct Todo {
    id: Uuid,
    title: Arc<str>,
    completed: bool,
}

#[derive(Deserialize)]
struct CreateTodo {
    title: String,
}

#[derive(FromRow)]
struct TodoRow {
    id: Uuid,
    title: String,
    completed: bool,
}

impl TodoRow {
    fn into_todo(self) -> Todo {
        Todo {
            id: self.id,
            title: self.title.into(),
            completed: self.completed,
        }
    }
}

Error Handling

Error handling remains largely the same - we still have the same error enum and we still use Results throughout the codebase.

What is changing is now we're adding a layout to the IntoResponse implementation to tell Axum how it can render this.

#[derive(Debug)]
enum TodoError {
    NotFound(Uuid),
    Internal(sqlx::Error),
}

impl From<sqlx::Error> for TodoError {
    fn from(e: sqlx::Error) -> Self {
        TodoError::Internal(e)
    }
}

impl IntoResponse for TodoError {
    fn into_response(self) -> axum::response::Response {
        match self {
            TodoError::NotFound(id) => (
                StatusCode::NOT_FOUND,
                layout(
                    "Not Found",
                    html! {
                        h1 { "Not Found" }
                        p { "Todo " (id) " not found." }
                        a href="/" { "Back to list" }
                    },
                ),
            )
                .into_response(),
            TodoError::Internal(e) => (
                StatusCode::INTERNAL_SERVER_ERROR,
                layout(
                    "Error",
                    html! {
                        h1 { "Internal Error" }
                        p { (e) }
                        a href="/" { "Back to list" }
                    },
                ),
            )
                .into_response(),
        }
    }
}

Service Trait + Implementation

The service trait and implementation are similarly the exact same. They're doing the data fetches from the db so adding a rendering layer on top doesn't need to change anything about the core logic.


#[async_trait]
trait TodoService: Send + Sync {
    async fn list(&self) -> Result<Vec<Todo>, TodoError>;
    async fn get(&self, id: Uuid) -> Result<Todo, TodoError>;
    async fn create(&self, input: CreateTodo) -> Result<Todo, TodoError>;
    async fn complete(&self, id: Uuid) -> Result<Todo, TodoError>;
    async fn delete(&self, id: Uuid) -> Result<Todo, TodoError>;
}

// SqliteTodoService

struct SqliteTodoService {
    pool: SqlitePool,
}

impl SqliteTodoService {
    async fn new(pool: SqlitePool) -> Self {
        sqlx::query(
            "CREATE TABLE IF NOT EXISTS todos (
                id TEXT PRIMARY KEY NOT NULL,
                title TEXT NOT NULL,
                completed BOOLEAN NOT NULL DEFAULT FALSE
            )",
        )
        .execute(&pool)
        .await
        .unwrap();
        Self { pool }
    }
}

#[async_trait]
impl TodoService for SqliteTodoService {
    async fn list(&self) -> Result<Vec<Todo>, TodoError> {
        let todos = sqlx::query_as::<_, TodoRow>("SELECT id, title, completed FROM todos")
            .fetch_all(&self.pool)
            .await?;
        Ok(todos.into_iter().map(TodoRow::into_todo).collect())
    }

    async fn get(&self, id: Uuid) -> Result<Todo, TodoError> {
        sqlx::query_as::<_, TodoRow>("SELECT id, title, completed FROM todos WHERE id = ?")
            .bind(id)
            .fetch_optional(&self.pool)
            .await?
            .map(TodoRow::into_todo)
            .ok_or(TodoError::NotFound(id))
    }

    async fn create(&self, input: CreateTodo) -> Result<Todo, TodoError> {
        let id = Uuid::now_v7();
        sqlx::query("INSERT INTO todos (id, title, completed) VALUES (?, ?, FALSE)")
            .bind(id)
            .bind(&input.title)
            .execute(&self.pool)
            .await?;
        Ok(Todo {
            id,
            title: input.title.into(),
            completed: false,
        })
    }

    async fn complete(&self, id: Uuid) -> Result<Todo, TodoError> {
        sqlx::query_as::<_, TodoRow>(
            "UPDATE todos SET completed = TRUE WHERE id = ? RETURNING id, title, completed",
        )
        .bind(id)
        .fetch_optional(&self.pool)
        .await?
        .map(TodoRow::into_todo)
        .ok_or(TodoError::NotFound(id))
    }

    async fn delete(&self, id: Uuid) -> Result<Todo, TodoError> {
        sqlx::query_as::<_, TodoRow>(
            "DELETE FROM todos WHERE id = ? RETURNING id, title, completed",
        )
        .bind(id)
        .fetch_optional(&self.pool)
        .await?
        .map(TodoRow::into_todo)
        .ok_or(TodoError::NotFound(id))
    }
}

Views

Where most of the changes are is adding views.

  • Layout - to give the base layout of the page
  • Everything else - Small view fragments to render the appropriate items
fn layout(title: &str, content: Markup) -> Markup {
    html! {
        (DOCTYPE)
        html {
            head {
                meta charset="utf-8";
                meta name="viewport" content="width=device-width, initial-scale=1";
                title { (title) " - Todos" }
                style {
                    "body { font-family: system-ui, sans-serif; max-width: 600px; margin: 2rem auto; padding: 0 1rem; }"
                    "form { display: inline; }"
                    "input[type=text] { padding: 0.4rem; font-size: 1rem; }"
                    "button { padding: 0.4rem 0.8rem; font-size: 1rem; cursor: pointer; }"
                    ".todo { display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem 0; border-bottom: 1px solid #eee; }"
                    ".todo .title { flex: 1; }"
                    ".completed .title { text-decoration: line-through; color: #999; }"
                }
            }
            body {
                (content)
            }
        }
    }
}

fn todo_item(todo: &Todo) -> Markup {
    html! {
        div class={ "todo" @if todo.completed { " completed" } } {
            span class="title" {
                a href={ "/todos/" (todo.id) } { (todo.title) }
            }
            @if !todo.completed {
                form method="post" action={ "/todos/" (todo.id) "/complete" } {
                    button type="submit" { "Complete" }
                }
            }
            form method="post" action={ "/todos/" (todo.id) "/delete" } {
                button type="submit" { "Delete" }
            }
        }
    }
}

fn index_page(todos: &[Todo]) -> Markup {
    layout("Home", html! {
        h1 { "Todos" }
        form method="post" action="/todos" {
            input type="text" name="title" placeholder="What needs to be done?" required;
            " "
            button type="submit" { "Add" }
        }
        div {
            @for todo in todos {
                (todo_item(todo))
            }
        }
        @if todos.is_empty() {
            p { "No todos yet. Add one above!" }
        }
    })
}

fn todo_page(todo: &Todo) -> Markup {
    layout(&todo.title, html! {
        h1 { (todo.title) }
        p {
            "Status: "
            @if todo.completed { "Completed" } @else { "Pending" }
        }
        p { "ID: " code { (todo.id) } }
        div {
            @if !todo.completed {
                form method="post" action={ "/todos/" (todo.id) "/complete" } {
                    button type="submit" { "Complete" }
                }
                " "
            }
            form method="post" action={ "/todos/" (todo.id) "/delete" } {
                button type="submit" { "Delete" }
            }
            " "
            a href="/" { "Back to list" }
        }
    })
}

Routes + Handlers

The routes and handlers look almost identical to what we had before except there's an extra step in them:

  • Call service, get result
  • Pipe result into the view functions, optionally use redirect after POSTs

Note that create_todo takes in a Form<CreateTodo> and returns a redirect to /. The Form is deserializing the HTML form submission into the CreateTodo struct and then we redirect the whole request back to index so that the page refreshes with the up-to-date data.

async fn index(State(service): State<Arc<dyn TodoService>>) -> Result<Markup, TodoError> {
    let todos = service.list().await?;
    Ok(index_page(&todos))
}

async fn get_todo(
    State(service): State<Arc<dyn TodoService>>,
    Path(id): Path<Uuid>,
) -> Result<Markup, TodoError> {
    let todo = service.get(id).await?;
    Ok(todo_page(&todo))
}

async fn create_todo(
    State(service): State<Arc<dyn TodoService>>,
    Form(input): Form<CreateTodo>,
) -> Result<Redirect, TodoError> {
    service.create(input).await?;
    Ok(Redirect::to("/"))
}

async fn complete_todo(
    State(service): State<Arc<dyn TodoService>>,
    Path(id): Path<Uuid>,
) -> Result<Redirect, TodoError> {
    service.complete(id).await?;
    Ok(Redirect::to("/"))
}

async fn delete_todo(
    State(service): State<Arc<dyn TodoService>>,
    Path(id): Path<Uuid>,
) -> Result<Redirect, TodoError> {
    service.delete(id).await?;
    Ok(Redirect::to("/"))
}

// Main

#[tokio::main]
async fn main() {
    let pool = SqlitePool::connect("sqlite:todos.db?mode=rwc")
        .await
        .unwrap();

    let service: Arc<dyn TodoService> = Arc::new(SqliteTodoService::new(pool).await);

    let app = Router::new()
        .route("/", get(index))
        .route("/todos", post(create_todo))
        .route("/todos/{id}", get(get_todo))
        .route("/todos/{id}/complete", post(complete_todo))
        .route("/todos/{id}/delete", post(delete_todo))
        .with_state(service);

    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
    println!("Listening on http://localhost:3000");
    axum::serve(listener, app).await.unwrap();
}

Next

So that's a simple single-file fullstack webapp in Rust.

If you want to see the full source code so you can clone and run this example yourself, check it out in the HAMY LABS Code Examples repo on GitHub. This is available to HAMINIONs Members so join to get access and support me making more tutorials like this one.

If you want to see how I spin up fullstack Rust webapps myself, take a look at CloudSeed Rust.

If you liked this post you might also like:

Want more like this?

The best way to support my work is to like / comment / share this post on your favorite socials.

Built with CloudSeed Rust