home tools

Using MiniJinja Templates for Dynamic Web Pages with Rust's Axum Server

July 2026

I use Axumaxum to power a local admin server for this site. It's provides reports on the site's current build state and various CRUD operations to manage content. These tools are Server Side Renderedssr using the MiniJinjamj template engine.

MiniJinja sets up an initial environment before rendering templates. I use axum's `State` to load and store the environment for the admin server when it initializes. Without that, it would have to initialize on every page request.

Here's what the setup looks like:

Files

Cargo.toml

[package]
name = "minijinja_templates"
version = "0.1.0"
edition = "2024"

[dependencies]
anyhow = "1.0.103"
axum = "0.8.9"
chrono = "0.4.45"
minijinja = { version = "2.21.0", features = ["custom_syntax", "debug"] }
tokio = { version = "1.52.3", features = ["macros", "rt-multi-thread"] }

src/main.rs

use anyhow::Result;
use axum::extract::State;
use axum::http::StatusCode;
use axum::{Router, response::Html, routing::get};
use chrono::offset::Utc;
use minijinja::syntax::SyntaxConfig;
use minijinja::{Environment, Value, context, path_loader};
use std::sync::Arc;

struct AppState {
  env: Environment<'static>,
}

#[tokio::main]
async fn main() -> Result<()> {
  let env = get_env()?;
  let app_state = Arc::new(AppState { env });
  let app = Router::new()
    .route("/", get(handle_home))
    .with_state(app_state);
  let listener = tokio::net::TcpListener::bind("127.0.0.1:3737")
    .await
    .unwrap();
  println!("listening on {}", listener.local_addr().unwrap());
  axum::serve(listener, app).await?;
  Ok(())
}

fn date() -> Value {
  let d = Utc::now();
  Value::from_safe_string(format!("{}", d))
}

fn get_env() -> Result<Environment<'static>> {
  let mut env = Environment::new();
  env.set_syntax(
    SyntaxConfig::builder()
      .block_delimiters("[!", "!]")
      .variable_delimiters("[@", "@]")
      .comment_delimiters("[#", "#]")
      .build()
      .unwrap(),
  );
  env.set_loader(path_loader("templates"));
  env.add_function("date", date);
  Ok(env)
}

async fn handle_home(
  State(state): State<Arc<AppState>>
) -> Result<Html<String>, StatusCode> {
  let context = context!(id => "home page");
  render("index.html", context, State(state))
}

fn render(
  template: &str,
  context: Value,
  State(state): State<Arc<AppState>>,
) -> Result<Html<String>, StatusCode> {
  let template = match state.env.get_template(template) {
    Ok(t) => t,
    Err(e) => {
      println!("ERROR: {}", e);
      return Err(StatusCode::INTERNAL_SERVER_ERROR);
    }
  };
  match template.render(context) {
    Ok(r) => Ok(Html(r)),
    Err(e) => {
      println!("ERROR: {}", e);
      Err(StatusCode::INTERNAL_SERVER_ERROR)
    }
  }
}

templates/index.html

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  </head>
  <body>
    <h1>[@ date() @]</h1>
  </body>
</html>

Notes

Wrap-Up

I'm really happy with this approach. MiniJinja offers a ton of functionality out of the box. One of those features is creating custom functions. The ability to call those functions each time the page is requested is to populate the template covers every scenario I can think of.

-a

end of line

Footnotes