A Better Way to Use Tera in Actix with Snapfire 0.4.0

Updated on

DISCLAIMER: Expressed views on this blog are my own.

If you've ever built a server-rendered web application with Actix Web and the Tera templating engine, you know the rhythm of the development dance all too well:

  1. Make a change to your .html template.
  2. Save the file.
  3. Go to your terminal, hit Ctrl+C.
  4. Run cargo run.
  5. Wait for the compile.
  6. Go back to your browser and hit refresh.

This cycle is a productivity killer. It's a constant context switch that pulls you out of your flow. For years, developers in other ecosystems have enjoyed "hot reloading" or "live reloading," where changes are reflected instantly. I wanted that experience for my Rust web projects.

Today, I'm excited to announce the release of Snapfire 0.4.0, a library I've built to solve this exact problem.

Snapfire is an ergonomic web templating library with an integrated live-reload server, featuring first-class support for Tera and Actix Web.

You can find it here:

The "Before": Manual Tera in Actix

Let's be honest about how most of us start with Tera in an Actix project. We reach for lazy_static to create a global, static Tera instance.

// in src/server/templates.rs
use lazy_static::lazy_static;
use tera::Tera;

lazy_static! {
  pub static ref TEMPLATES: Tera = {
    // This runs once, at the start of the program.
    let mut tera = Tera::new("templates/**/*").expect("Tera parsing error");
    tera.autoescape_on(vec![".html"]);
    tera
  };
}

This works, but it has some major drawbacks:

  • No Live Reload: The templates are parsed once when the server starts. To see a change, you have to restart the entire application.
  • Boilerplate in Handlers: Every single route handler ends up with repetitive logic to render a template, handle potential errors, and build an HttpResponse.

A typical handler looks like this:

// A typical handler BEFORE Snapfire
use crate::server::templates::TEMPLATES;

async fn index(req: HttpRequest) -> impl Responder {
  let mut context = Context::new();
  context.insert("page_title", "Home");

  match TEMPLATES.render("pages/home.html", &context) {
    Ok(body) => {
      HttpResponse::Ok()
        .content_type("text/html; charset=utf-8")
        .body(body)
    }
    Err(err) => {
      log::error!("Rendering error: {}", err);
      HttpResponse::InternalServerError().finish()
    }
  }
}

This is fine for one route, but across dozens of routes, it's a lot of repeated, noisy code.

The "After": The Snapfire Experience

Snapfire replaces all of that with a clean, managed application state and a ridiculously simple rendering API.

Step 1: The Setup

Instead of a lazy_static, you use the TeraWebBuilder during your app initialization.

// In your main.rs
use snapfire::TeraWeb;

let app_state = TeraWeb::builder("templates/**/*.html")
    .add_global("site_name", "My Awesome Site")
    // Tell Snapfire to watch your static assets, too!
    .watch_static("static/css")
    .build()
    .expect("Failed to build Snapfire");

// Then, add it to your Actix App
HttpServer::new(move || {
    App::new()
        .app_data(web::Data::new(app_state.clone()))
        // The middleware and configure calls are no-ops in production
        .wrap(snapfire::actix::dev::InjectSnapfireScript::default())
        .configure(|cfg| app_state.configure_routes(cfg))
        // ... your routes ...
})

Step 2: The Handler (The Payoff)

That verbose handler from before? It now looks like this:

// The same handler AFTER Snapfire
async fn index(app_state: web::Data<TeraWeb>) -> impl Responder {
  let mut context = Context::new();
  context.insert("page_title", "Home");
  
  // That's it.
  app_state.render("pages/home.html", context)
}

That's the entire function.

The ugly match statement is gone. The manual HttpResponse construction is gone. The error handling is handled for you. It's clean, concise, and focused on what matters: your page's data.

So What Do You Get?

By making this change, you unlock a much better development experience:

  • 🔥 Template Live Reload: Change an .html file, save it, and your browser will perform a full page refresh automatically. No more restarting your server.
  • 🎨 CSS Hot Swapping: Change a .css file in a watched static directory, and Snapfire will push the change to the browser without a full page reload. Your styles update instantly.
  • ✅ Zero-Cost in Production: The file watcher, WebSocket server, and script injection middleware are all gated by a default feature flag, dev-reload. When you build for production with --no-default-features, all of that code is completely compiled out. There is zero performance overhead.

The Vision

Right now, Snapfire provides a first-class integration for Tera and Actix Web because that's the stack I use most often. But it's designed with a modular core. In the future, I'd love to see or add support for other backends like Axum, Rocket, Handlebars, or Maud. The goal is to bring this high-productivity workflow to as much of the Rust web ecosystem as possible.

Give It a Try!

I'm really excited about this release and I hope it can save you some of the time and frustration it has saved me. You can get started right now.

If you have any feedback, find a bug, or have an idea for an improvement, please open an issue on GitHub. I'd love to hear what you think


You just read "A Better Way to Use Tera in Actix with Snapfire 0.4.0". Please share if you liked it!
You can read more recent posts here.