Serving Astro with Rust


As some of you might know, for the past few months, my free time is spent on JustFax. I also published few blog posts about this project: here, here, and here. Let’s try to milk this project some more, this time with Rust and Astro.

The entire website for JustFax is was fully server rendered. Like in the old times. You would hit a URL, and the server would respond with an HTML. Interactivity was handled by htmx and a bunch of vanilla JavaScript.

The problems with server side rendering

Templates are hard to maintain

Firstly, let’s not confuse Server Side Rendering (SSR) and Server Side Generation (SSG). The former renders the HTML on demand per request, while the latter generates HTML files beforehand to be served by a server.

I was using the tera templating engine. Tera works with a format similar to Jinja. Overall, the syntax is nice, and tera is great. I was even able to implement hot reloading of templates, so I could run the rust server and edit the HTML, without the need to restart the server.

However, as the project grew bigger, it became harder to maintain the HTML templates and, more importantly, the context I would pass to the templates. Templates need variables. Variables are passed from Rust to the tera engine by means of serialization (if I’m not mistaken via serde_json). In reality, they are completely detached from each other. And this brings me to the first issue of server side rendering: errors in rendering the templates are run time.

This means that if I misspelled a variable, the template would fail to render. Refactoring code, and renaming variables—became a nightmare. I needed to check each page manually before committing to deploying a new version. I was considering switching to askama, which is a type-safe, Jinja-like, compiled template engine. But eventually decided to generate all the HTML beforehand.

In addition to that, Jinja, or at least the way it’s implemented in tera, is very limiting. I would end up defining variables that are used purely for HTML purposes, in Rust, because I wasn’t able to achieve the same inside a template. Examples include scenarios like: converting a vector of structs into JSON; and various translation shenanigans. This is in no way a criticism towards tera; tera is a great templating engine, and I would definitely use it again in the future (if askama won’t prove to be better). I never worked with Jinja or Django, so I assume these might be limitations of the templating language itself, rather than the tool.

Oh, and one last annoyance. Jinja is poorly supported in vim. So I had to fight the editor with stupid errors. One great example is from this piece of code:

{% if country is defined and country is string -%}
const INITIAL_COUNTRY = "{{country}}";
{% else -%}
    {% if LANG == "en" -%}
      const INITIAL_COUNTRY = "us";
    {% elif LANG == "de" -%}
      const INITIAL_COUNTRY = "de";
    {% elif LANG == "fr" -%}
      const INITIAL_COUNTRY = "fr";
    {% endif %}
{% endif -%}

The LSP would yell at me for trying to re-declare INITIAL_COUNTRY.

Everything is a run-time problem

With server side templates, everything becomes a run-time problem. Forgot to declare a variable? Run time errors. Extending a base template that does not exist? Run time errors.

On a small website with 2–3 pages, it’s easy to check each page manually. But at some point it becomes too complicated, and I wish I could outsource this to some build step. Maybe compiled templates like askama would help here, but I haven’t tried them.

In addition to that, maintaining SSR requires a lot of tools. Rust is amazing in run-time, but not that in compile-time. Removing tera and other crates that I used that helps with template rendering—improved the compile time of the project. Previously, it would take about 4 minutes to compile the rust code in CI. After removing tera, markdown related crates, and other crates used for formatting (for example phone number formatting)—I was able to reduce the compilation time to 3 minutes, which might not sound like a lot, but this is a great improvement.

In the end, if I look at it, the HTML was completely static. There was no need to re-render it for each request, and the dynamic parts were small enough to be able to generate them in the browser without incurring any performance penalty. I also learned the hard way that Rust is way behind JavaScript in terms of l10n and i18n. While it has great support for translations (I personally like project fluent by Mozilla)—it has no crates to format dates/numbers/currencies. There are crates like icu that are in development, that essentially tap into the Common Locale Data Repository (I wrote about CLDR back in 2015). This would allow Rust to have the same capabilities as JavaScript has through the Intl API, but currently it’s still a work-in-progress.

And so, I decided to turn to my second favorite tool.

Hello Astro-naut

I’m a big fan of Astro, and in fact this blog is powered by Astro. Astro, unlike other frameworks, does not force you to use a particular frontend framework, you can use vanilla JavaScript or TypeScript, or you can mix them all together. It’s fast enough to generate hundreds of pages. It comes with a lot of tools for websites like automatic sitemap generation, assets optimization and bundling, etc.

Most of the issues I described above are also solved by Astro. Template data and representation, are connected, especially if you opt for TypeScript. It will be impossible to generate a page that includes a non-existing Astro component, for example. Astro comes with superb support for Markdown content collection, which makes it super easy to generate content. When I was using Rust, I had to sort-of reimplement what Astro does. It didn’t work well.

L10n and i18n are also solved with Astro. You can pick any translation library you want, including project fluent from Mozilla. And you have access to Intl for formatting numbers, dates, and currencies.

The only problem with Astro is that it’s a static site generator. Wait? What?! Yes, I know I said that I wanted to switch to SSG from SSR, how come it’s a downside?

Well, as I described in this blog post—Web app localization in Rust—I need a server in order to do locale negotiation. Before rendering the initial HTML, I need to query cookies and the Accept-Language header, and determine the preferred language of the visitor. This can be done with JavaScript, for example by using the window.navigator.languages property (which MDN claims to be the same values as Accept-Language header). However, this would harm usability, as Google won’t be able to index the website correctly.

Astro, does, however take care of this as well. If you decide to opt for hybrid or server output for your Astro website, you will gain access to Astro.preferredLocale and Astro.preferredLocaleList properties (as describe in the Internalization article from Astro Docs). But opting in for hybrid or server outputs creates a problem. You are now locked into either using Netlify or Vercel edge functions (which has official Astro adapters), or go back to Node. Neither of them I wanted to do. I have my locale negotiation code in Rust, I want it to stay in Rust. In addition to that, the locale negotiation by Astro does not take into account the cookie that I set in order to avoid sending an English speaking German person to the German version of the website, even though he insisted for English interface, unlike Google is doing.

And so, I was left with the only option, serve Astro website with Rust.

Serving Astro website with Rust

Astro generates, by default, your entire website structure in the dist folder. HTML pages as well as additional resources will be there. All is left, is just to expose this folder to the web.

One approach I wanted to use, is to serve the website by Caddy. I already use Caddy as reverse-proxy for the Rust server, and Caddy is capable of serving folders. However, figuring out the correct handlers order for Caddy—made my cabesa hurt. You see, theoretically I could point / to / on Rust server; /api to /api on Rust server; /en to /dist/en; /de to /dist/de; etc. But what do I do with static resources like JavaScript, sitemap, robots.txt, etc? You see, it makes my head hurt. So this option was out of the window.

Let’s now talk about Rust. I use axum as my web framework. Axum is capable of serving directories using the ServeDir service. You give it a path to a directory, and it will serve the files from there. So I have a regular axum route that handles the / url; route that handle /api url(s); and everything else is fallen back to ServeDir. This covers 99% of the files. The last 1% are 404 files. Astro will allow you to define 404.astro for a custom 404 page, and services like Netlify or Vercel will be able to route to it, but not axum.

If axum fails to find a matching route for the request, nor it finds a static file inside the provided ServeDir path, you are able to implement a custom 404 handler.

let fallback_service = ServeDir::new(public_path)
    .not_found_service(Handler::with_state(handle_404, context.clone()));

Inside handle_404 you can do all kinds of things. I do locale negotiation to decide which version of the 404 page to serve, but eventually it boils down to

match ServeFile::new(path.as_path())
    Ok(res) => res.into_response(),
    Err(e) => (StatusCode::NOT_FOUND).into_response()

While path is the path to [en|de]/404/index.html.

Another thing with Astro, is that it has a great asset management pipeline. Each static resource will be processed by Astro and a hashed name file will be put it _astro directory. Since the hash of the file represents the hashed content, if the file did not change, it means that it will have the same hash. And this means that _astro is a great directory to cache aggressively.

I had to fight a bit with axum in order to understand how to send headers alongside ServeDir file, but eventually I was able to figure it out.

let public_path = Path::new(&config.server.www_dir);
let fallback_service = ServeDir::new(public_path)

    .fallback(get(|req: Request| async move {
        let (mut parts, body) = req.into_parts();
        let uri: OriginalUri = parts.extract().await?;

        let req = Request::from_parts(parts, body);
        match fallback_service.oneshot(req).await {
            Ok(mut res) => match res.status() {
                StatusCode::OK => {
                    if uri.path().contains("/_astro/")
                            "public, max-age=31536000".parse().unwrap(),
                _ => Ok(res),
            Err(e) => {
                tracing::error!("fallback_service error: {}", e);

This essentially uses the ServeDir service through the oneshot request, and based on the request uri it sets the cache headers for _astro folder.

That’s it. That’s the blog post.

Share this:

Published by

Dmitry Kudryavtsev

Dmitry Kudryavtsev

Senior Software Engineer / Tech Lead / Consultant

With more than 14 years of professional experience in tech, Dmitry is a generalist software engineer with a strong passion to writing code and writing about code.

Technical Writing for Software Engineers - Book Cover

Recently, I released a new book called Technical Writing for Software Engineers - A Handbook. It’s a short handbook about how to improve your technical writing.

The book contains my experience and mistakes I made, together with examples of different technical documents you will have to write during your career. If you believe it might help you, consider purchasing it to support my work and this blog.

Get it on Gumroad or Leanpub

From Applicant to Employee - Book Cover

Were you affected by the recent lay-offs in tech? Are you looking for a new workplace? Do you want to get into tech?

Consider getting my and my wife’s recent book From Applicant to Employee - Your blueprint for landing a job in tech. It contains our combined knowledge on the interviewing process in small, and big tech companies. Together with tips and tricks on how to prepare for your interview, befriend your recruiter, and find a good match between you and potential employer.

Get it on Gumroad or LeanPub