Back Original

Building a fast website with the MASH stack in Rust

I'm building Scour, a personalized content feed that sifts through noisy feeds like Hacker News Newest, subreddits, and blogs to find great content for you. It works pretty well -- and it's fast. Scour is written in Rust and if you're building a website or service in Rust, you should consider using this "stack".

After evaluating various frameworks and libraries, I settled on a couple of key ones and then discovered that someone had written it up as a stack. Shantanu Mishra described the same set of libraries I landed on as the "mash 🥔 stack" and gave it the tagline "as simple as potatoes". This stack is fast and nice to work with, so I wanted to write up my experience building with it to help spread the word.

TL;DR: The stack is made up of Maud, Axum, SQLx, and HTMX and, if you want, you can skip down to where I talk about synergies between these libraries. (Also, Scour is free to use and I'd love it if you tried it out and posted feedback on the suggestions board!)

Server-Side Rendered HTML

Scour uses server-side rendered HTML, as opposed to a Javascript or WebAssembly frontend framework. Why?

First, browser are fast at rendering HTML. Really fast.

Second, Scour doesn't need a ton of fancy interactivity and I've tried to apply the "You aren't gonna need it" principle while building it. Holding off on adding new tools helps me understand the tools I do use better. I've also tried to take some inspiration from Herman from BearBlog's approach to "Building software to last forever". HTML templating is simple, reliable, and fast.

HTML Templating Options

Since I wanted server-side rendered HTML, I needed a templating library and Rust has plenty to choose from. The main two decisions to make were:

  1. Should templates be evaluated at compile time or runtime?
  2. Should templates be included in your Rust source code or in separate files?

Here is a non-exhaustive list of popular template engines and where they fall on these two axes:

Compile Time Runtime
Rust Source maud
tinytemplate
Separate Files askama / rinja tera

I initially picked askama because of its popularity, performance, and type safety. (I quickly passed on all of the runtime-evaluated options because I couldn't imagine going back to a world of runtime type errors. Part of the reason I'm writing Rust in the first place is compile-time type safety!)

After two months of using askama, however, I got frustrated with its developer experience. Every addition to a page required editing both the Rust struct and the corresponding HTML template. Furthermore, extending a base template for the page header and footer was surprisingly tedious. askama templates can inherit from other templates. However, any values passed to the base template (such as whether a user is logged in) must be included in every page's Rust struct, which led to a lot of duplication. This experience sent me looking for alternatives.

Maud - "A macro for writing HTML"

Maud is a macro for writing fast, type-safe HTML templates right in your Rust source code. The format is concise and makes it easy to include values from Rust code.

The Hello World example shows how you can write HTML tags, classes, and attributes without the visual noise of angle brackets and closing tags:

html! {
    h1 { "Hello, world!" }
    p.intro {
        "This is an example of the "
        a href="https://github.com/lambda-fairy/maud" { "Maud" }
        " template language."
    }
}

Rust values can be easily spliced into templates (HTML special characters are automatically escaped):

let best_pony = "Pinkie Pie";
let numbers = [1, 2, 3, 4];
html! {
    p { "Hi, " (best_pony) "!" }
    p {
        "I have " (numbers.len()) " numbers, "
        "and the first one is " (numbers[0])
    }
}

Control structures like @if, @else, @for, @let, and @match are also very straightforward:

let user = Some("Pinkie Pie");
let names = ["Applejack", "Rarity", "Fluttershy"];
html! {
    p {
        "Hello, "
        @if let Some(name) = user {
            (name)
        } @else {
            "stranger"
        }
        "!"
    }
    p { "My favorite ponies are:" }
        ol {
            @for name in &names {
                li { (name) }
            }
        }
}

Partial templates are also easy to reuse by turning them into small functions that return Markup:

use maud::{DOCTYPE, html, Markup};

/// A basic header with a dynamic `page_title`.
fn header(page_title: &str) -> Markup {
    html! {
        (DOCTYPE)
        meta charset="utf-8";
        title { (page_title) }
    }
}

/// A static footer.
fn footer() -> Markup {
    html! {
        footer {
            a href="rss.atom" { "RSS Feed" }
        }
    }
}

/// The final Markup, including `header` and `footer`.
///
/// Additionally takes a `greeting_box` that's `Markup`, not `&str`.
pub fn page(title: &str, greeting_box: Markup) -> Markup {
    html! {
        // Add the header markup to the page
        (header(title))
        h1 { (title) }
        (greeting_box)
        (footer())
    }
}

All in all, Maud provides a pleasant way to write HTML components and pages. It also ties in nicely with the rest of the stack (more on that later).

Axum - "Ergonomic and modular web framework"

Axum is a popular web framework built by the Tokio team. The framework uses functions with extractors to declaratively parse HTTP requests.

The Hello World example illustrates building a router with multiple routes, including one that handles a POST request with a JSON body and returns a JSON response:

#[tokio::main]
async fn main() {
    let app = Router::new()
        .route("/", get(root))
        .route("/users", post(create_user));

    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

async fn root() -> &'static str {
    "Hello, World!"
}

async fn create_user(
    Json(payload): Json<CreateUser>,
) -> (StatusCode, Json<User>) {
    let user = User {
        id: 1337,
        username: payload.username,
    };

    (StatusCode::CREATED, Json(user))
}

#[derive(Deserialize)]
struct CreateUser {
    username: String,
}

#[derive(Serialize)]
struct User {
    id: u64,
    username: String,
}

Axum extractors make it easy to parse values from HTTP bodies, paths, and query parameters and turn them into well-defined Rust structs. And, as we'll see later, it plays nicely with the rest of this stack.

Every named stack needs a persistence layer. SQLx is a library for working with SQLite, Postgres, and MySQL from async Rust.

SQLx has a number of different ways of working with it, but I'll show one that gives a flavor of how I use it:

#[derive(sqlx::FromRow, Debug, PartialEq, Eq)]
struct User {
    id: i64,
    username: String,
}

let pool = SqlitePool::connect("db.sqlite").await?;

let user_id = 1337;
let user: Option<User> = sqlx::query_as("SELECT id, username FROM users WHERE id = $1")
    .bind(user_id)
    .fetch_optional(&pool)
    .await?;

You can derive the FromRow trait for structs to map between the database row and your Rust types.

Note that you can derive both FromRow and serde's Serialize and Deserialize on the same structs to use them all the way from your database to the Axum layer. However, in practice I've often found that it is useful to separate the database types from those used in the server API -- but it's easy to define From implementations to map between them.

The last part of the stack is HTMX. It is a library that enables you to build fairly interactive websites using a handful of HTML attributes that control sending HTTP requests and handling their responses. While HTMX itself is a Javascript library, websites built with it often avoid needing to use custom Javascript directly.

For example, this button means "When a user clicks on this button, issue an AJAX request to /clicked, and replace the entire button with the HTML response".

<button hx-post="/clicked" hx-swap="outerHTML">
	Click Me
</button>

Notably, this snippet will replace just this button with the HTML returned from /clicked, rather than the whole page like a plain HTML form would.

HTMX has been having a moment, in part due to essays like The future of HTMX where they talked about "Stability as a Feature" and "No New Features as a Feature". This obviously stands in stark contrast to the churn that the world of frontend Javascript frameworks is known for.

There is a lot that can and has been written about HTMX, but the logic clicked for me after watching this interview with the creator of it.

The elegance of HTMX -- and the part that makes its promise of stability credible -- is that it was built from first principles to generalize the behavior already present in HTML forms and links.

Specifically, (1) HTML forms and links (2) submit GET or POST HTTP requests (3) when you click a Submit button and (4) replace the entire screen with the response. HTMX asks and answers the questions:

  • Why should only <a> & <form> be able to make HTTP requests?
  • Why should only click & submit events trigger them?
  • Why should only GET & POST methods be available?
  • Why should you only be able to replace the entire screen?

By generalizing these behaviors, HTMX makes it possible to build more interactive websites without writing custom Javascript -- and it plays nicely with backends written in other languages like Rust.

Caching HTMX Javascript... forever

Since we're talking about Rust and building fast websites, it's worth emphasizing that while HTMX is a Javascript library, it only needs to be loaded once. Updating your code or website behavior will have no effect on the HTMX libraries, so you can use the Cache-Control immutable directive to tell browsers or other caches to indefinitely store the specific versions of HTMX and any extensions you're using.

The first visit might look like this: Screenshot 2025-03-10 at 6

But subsequent visits only need to load the HTML: Screenshot 2025-03-10 at 6

This makes for even faster page loads for return users.

MASH Stack Synergies

Overall, I've had a good experience building with this stack, but I wanted to highlight a couple of places where the various components complemented one another in nice ways.

Earlier, I mentioned my frustration with askama, specifically around reusing a base template that includes different top navigation bar items based on whether a user is logged in or not. I was wondering how to do this with Maud, when I came across this Reddit question: Users of maud (and axum): how do you handle partials/layouting? David Pedersen, the developer of Axum, had responded with this gist.

In short, you can make a page layout struct that is an Axum extractor and provides a render method that returns Markup:

struct Layout {
    user: Option<User>,
}

impl<S> FromRequestParts<S> for PageLayout {
    type Rejection = ServerError;

    async fn from_request_parts(
        parts: &mut Parts,
        _state: &S,
    ) -> Result<Self, Self::Rejection> {
        let auth_session = parts.extract::<AuthSession>().await?;
        Ok(Layout { user: auth_session.user })
    }
}

impl PageLayout {
	pub fn render(&self, content: Markup) -> Markup {
		html! {
			(DOCTYPE)
			html {
				nav {
					@if let Some(user) = self.user {
						a href="/profile" { (user.username) }
					} @else {
						a href="/login" { "Login" }
					}
				}
				(content)
			}
		}
	}
}

When you use the PageLayout extractor in your page handler functions, the base template automatically has access to the components it needs from the request:

async fn page_handler(page_layout: PageLayout) -> Markup {
	page_layout.render(html! {
		p { "Some body content" }
	})
}

This approach makes it easy to reuse the base page template without needing to explicitly pass it any request data it might need.

(Thanks David Pedersen for the write-up -- and for your work on Axum!)

Maud's Markup implements Axum's IntoResponse

This is somewhat table stakes for HTML templating libraries, but it is a nice convenience that Maud has an Axum integration that enables directly return Markup from Axum routes (as seen in the examples just above).

Middleware for caching preloaded requests

HTMX has a number of very useful extensions, including the Preload extension. It preloads HTML pages and fragments into the browser's cache when users hover or start clicking on elements, such that the transitions happen nearly instantly.

The Preload extension sends the "HX-Preloaded": "true" header with every request it initiates, which pairs nicely with middleware that sets the cache response headers:

async fn cache_control_for_htmx_preload_requests(
    request: Request,
    next: Next,
) -> impl IntoResponse {
    let has_specific_header = request.headers().contains_key("hx-preloaded");

    let mut response = next.run(request).await;

    if has_specific_header && !response.headers().contains_key(CACHE_CONTROL) {
        response.headers_mut().insert(
            CACHE_CONTROL,
            HeaderValue::from_static("private, max-age=30"),
        );
    }

    response
}

(Of course, this same approach can be implemented with any HTTP framework, not just Axum.)

axum-htmx crate

Update: after writing this post, u/PwnMasterGeno on Reddit pointed out the axum-htmx crate to me.

This library includes Axum extractors and responders for all of the headers that HTMX uses.

For example, you can use the HX-Boosted header to determine if you need to send the full page or just the body content.

async fn get_index(HxBoosted(boosted): HxBoosted) -> impl IntoResponse {
    if boosted {
        // Send a partial template
    } else {
        // Send the full page
    }
}

axum-htmx also has a nice feature for cache management. It has a Layer that automatically sets the Vary component of the HTTP cache headers based on the request headers you use, which will ensure the browser correctly resends the request when the request changes in a meaningful way.

Less great parts of MASH

While I've overall been happy building with the MASH stack, here are the things that I've found to be less than ideal.

Compile Times

I would be remiss talking up this stack without mentioning one of the top complaints about most Rust development: compile times. When building purely backend services, I've generally found that Rust Analyzer does the trick well enough that I don't need to recompile in my normal development flow. However, with frontend changes, you want to see the effects of your edits right away.

During development, I use Bacon for recompiling and rerunning my code and I use tower-livereload to have the frontend automatically refresh.

Using some of Corrode's Tips For Faster Rust Compile Times, I've gotten it down to around 2.5 seconds from save to page reload. I'd love if it were faster, but it's not a deal-breaker for me.

For anyone building with the MASH stack, I would highly recommend splitting your code into smaller crates so that the compiler only has to recompile the code you actually changed.

Also, there's an unmerged PR for Maud to enable updating templates without recompiling, but I'm not sure if that will end up being merged.

If you have any other suggestions for bringing down compile times, I'd love to hear them!

Loading static HTML fragments

HTMX's focus on building interactivity through swapping HTML chunks sent from the backend sometimes feels overly clunky.

For example, the Click To Edit example is a common pattern involving replacing an Edit button with a form to update some information such as a user's contact details. The stock HTMX way of doing this is fetching the form component from the backend when the user clicks the button and swapping out the button for the form.

This feels inelegant because all of the necessary information is already present on the page, save for the actual form layout.

It seems like some users of HTMX combine it with Alpine.js, Web Components, or a little custom Javascript to handle this. For the moment, I've opted for the pattern lifted from the HTMX docs but I don't love it.

Conclusion: Give MASH a try!

If you're building a website and using Rust, give the MASH stack a try! Maud is a pleasure to use. Axum and SQLx are excellent. And HTMX provides a refreshing rethink of web frontends.

That said, I'm not yet sure if I would recommend this stack to everyone doing web development. If I were building a startup making a normal web app, there's a good chance that TypeScript is still your best bet. But if you are working on a solo project or have other reasons that you're already using Rust, give this stack a shot!

If you're already building with these libraries, what do you think? I'd love to hear about others' experiences.

Thanks to Alex Kesling for feedback on a draft of this post!


Discuss on r/rust, r/htmx or Hacker News.


If you haven't already signed up for Scour, give it a try and let me know what you think!


#rust #scour