jeff-mitchell.dev

mostly mis-adventures with programming languages...mostly...

Blog Posts


The Essence of Templating with Tera

2024-11-29

Introduction

Tera is a powerful and flexible template engine for the Rust language. What is a template engine? Well, it's a way for you the developer to build a server side rendered web application and use templates to describe the structure and content of the app. Rather than build each page individually, using Tera you describe the overall structure and format, and the data can be dynamically pulled in on the fly.

This is an introductory article, as such I want to focus on:

  • Initialize a new Rust project with a binary
  • Set up Tera
  • Basic usage
  • Optimization with OnceLock from the standard library

I'm not going to build a full application in this piece, but instead want to cover the basics of getting off the ground.

Let's go!

Initialize a New project

The starter we're going to create will be a simple Rust app which outputs raw HTML to the console. It won't have a web server and won't be tailored for deployment on Shuttle. The goal here is to give you the tools to explore on your own. Let's begin with Step #1, create a new Rust binary project:

cargo new --bin hello-world-tera

This will create a new project called "hello-world-tera" and will set you up with a binary crate so that you can run the project and observe the output.

Setting Up Tera

After you've changed into the hello-world-tera directory, Step #2 is to add the Tera crate as a dependency to your project.

cargo add tera

This will make the crate available in your project.

Now, in the root of your project, create a templates/ directory. This will serve as the central repository of all the templates you build for your project. As we'll see in a moment, at compile time they will all be pulled from this directory for rendering. Now we need an actual template. For the moment, create one called base.html. This will be the base template and serves as the baseline for the whole project.

<!DOCTYPE html>
<html>
  <head>
    <title>{{ title }}</title>
  </head>
  <body>
    <h1>Welcome to {{ site_name }}</h1>
    <p>{{ message }}</p>
  </body>
</html>

Here you can see a very rudimentary HTML file. It has some variables though, surrounded by {{ }}. This is the way we denote that Tera should inject something into the particular location. How exactly?

Rendering Templates

Alright, we've got a template, what next? Let's write some actual Rust code as Step #3. In the src directory, go to the main.rs file and type in the following code:

// src/main.rs

// dependences
use tera::{Tera, Context};

// main function
fn main() {
  let tera = Tera::new("templates/**/*").unwrap();  // constructor is fallible, so we unwrap() to get the good value
  let mut context = Context::new();
  context.insert("title", "First Steps with Tera");
  context.insert("site_name", "example.com");
  context.insert("message", "Hello, world!");

  let rendered = tera.render("base.html", &context).unwrap(); // render() method is fallible, so we unwrap()
  println!("{}", rendered);
}

Let's walk through it:

  • We need two things to render a template, the dynamic information, and a place to put it; we bring two things into scope from the Tera crate, namely the Tera type, which is a struct, and Context, which is also a struct
  • The Tera type and Context type are our two main tools for working with Tera templates
  • We create a new Tera instance (using the contents of the directory you created in the previous step) and bind that to a variable
  • Create an empty instance of Context which is effectively a holding container for the content to render into the template, and bind it to a variable
  • Fill in the empty context variable with our data. Remember those {{ }} in the template you made? Yes, we're filling all those in with this info.
  • There isn't any error handling happening here, .unwrap() will give us back good values from any fallible functions, but result in a panic otherwise

That's it! If you compile and run this code, you'll get your filled template rendered back to the console.

<!DOCTYPE html>
<html>
  <head>
    <title>First Steps with Tera</title>
  </head>
  <body>
    <h1>Welcome to example.com</h1>
    <p>Hello, world!</p>
  </body>
</html>

So, pretty easy eh? It is. There is one pitfall to watch out for though.

Optimizing Template Rendering

Templates are very expensive to render. The above example is trivial, but in something real, you don't want to be rendering the templates from scratch every time they are requested. Instead, you want to compile them once, then put them somewhere to be re-used later. This way, they're ready and the server isn't wasting time and resources re-building every template each time they are requested.

How can we achieve this? We lean on the standard library and pull in OnceLock, which allows us to create a thing and tuck it off in the corner to use later. OnceLock is thread-safe as it implements the Sync marker trait which denotes it is safe for the type to be referenced from multiple threads.

Modify your src/main.rs to look like this:

// src/main.rs

// dependences
use std::sync::OnceLock;
use tera::{Context, Error, Tera};

// declare a static variable to hold the initialized templates
static COMPILED_TEMPLATE: OnceLock<Tera> = OnceLock::new();

// function to create the tera template, handling any errors and returning them to the caller
fn create_template() -> Result<Tera, Error> {
    let mut base_template = Tera::new("templates/**/*")?;
    base_template.add_template_file("templates/base.html", Some("base"))?;
    Ok(base_template)
}

// function to build the Tera template, returns a Result type, where
// the Ok variant is the rendered template and the error is the Error type provided by Tera
fn get_template() -> Result<&'static Tera, Error> {
    let template = create_template()?;
    Ok(COMPILED_TEMPLATE.get_or_init(|| template))
}

// function which renders the Tera templates, returns a Result type, where
// the Ok variant is a string of HTML and the error is the Error type provided by Tera
fn render_template() -> Result<String, Error> {
    let mut context = Context::new();
    context.insert("title", "First Steps with Tera");
    context.insert("site_name", "example.com");
    context.insert("message", "Hello, world!");

    get_template()?.render("base.html", &context)
}

// main function
fn main() -> Result<(), Error> {
    let rendered = render_template();
    match rendered {
        Ok(template) => println!("{}", template),
        Err(e) => eprintln!("Error: {}", e),
    }

    Ok(())
}

There's a lot here, and I've handled errors. What changed and how is this better?

  • The OnceLock type is brought into scope from std::sync in the standard library
    • Fun fact: OnceLock used to be part of the once_cell crate, which was brought into the Rust standard library with version 1.70
  • The Context, Error, and Tera types are brought into scope from the Tera crate
  • We declare a static variable to hold our compiled template, it's initialized with an empty value by using the OnceLock::new() constructor
  • We need a separate function which gets our Tera instance started and adds in the base template we created in the templates/ directory
    • Errors could happen in this process, so to handle them, we use the ? operator to propagate any errors back to the caller
  • We have a function which get_template() which leverages the function we just created, any errors are propagated back to the caller
    • You'll see that the get_or_init() method accepts a closure, we pass in our template that we just created
    • The closure here wants to work with an actual good value, meaning we can't handle errors effectively inside the closure without an .unwrap(). I like to try to show how to handle errors, rather than not.
  • Now that our template is compiled and placed on a shelf, so to speak, we can render it with the desired content. Two things are needed, a template, and context. Remember those variables you put into the base.html above? That's the context information which gets inserted into the template.
  • Finally, we have a main function which calls our render_template() function and outputs the result, or outputs any errors

This approach is a better solution because we compile the templates once and they are saved in static memory and accessible in a thread-safe way.

That's it! You now know how to do things with Tera templating.

Common Mistakes

There is one aspect of Tera that you have to be careful with and that's pathing. In the initialization step above (the create_template() function), it's really important to get your paths correct. The Tera::new() constructor needs a path that's absolute from the root of your project. You should also be careful to use the glob format noted above to capture everything below the template root, assuming you want that. When you use add_template_file() to add in a template, you have to again use an absolute path relative to your root directory.

When deploying, take care to actually include your templates folder with the deployment files. Been there, done that, trust me.

Conclusion and Next Steps

To extend out this very basic starter, you can learn to work with multiple template files, by using the add_template_files() function. This function takes multiple files, by building their path and name info into a Vector. This example doesn't show you how to leverage templating in the context of a web server. You could do that very easily with Rocket or Axum. Rocket does a lot of the work for you, but in Axum you'll need to pull in tower-http as a dependency, to get access to ServeDir and it's methods for serving static files, which can be all the assets needed for your project, including the Tera templates.

Here are some resources to aid in your adventures with Tera:

Good luck! And have fun!