How to Improve The Jinja2 Experience with FastAPI

While FastAPI is a great first choice for any API development in Python, it’s often not considered when your primary goal is to use Jinja2 templates to return HTML to the user. It’s hard to match the Ecosystem and support that comes with Flask, but I found that with a helper class you can get a long way with FastAPI and Jinja2 making it a great choice for a traditional web application.

Make sure you check out this section of the docs, we’ll be using this as the basis moving forward.

Standardize Responses With Pydantic Models

Part of the reason I love FastAPI is Pydantic and the way it’s changed how I think about programming by thinking data-first and building functions and classes around those data objects. With that in mind, the first step is establishing a standard object that we’ll use to create and render all our templates.

In the code below we’re going to do a few things.

  1. We’ll use use_j2_templates to return a helper from FastAPI required for using Jinja2. We’ll also use the @lru_cache decorator to ensure that once it’s called, we’ll always get the same result and speed up responses.
  2. We’re creating a base model with a few properties:

j2: is the Jinja2Templates type that is

html: This will be the string representation of the template path.

css: A list of strings representing the path to CSS files

js: A list of strings representing the path to JavaScript files

request: The FastAPI Requests type

title: This will be used in your template to assign the page title.

data: This is an arbitrary dictionary of data. This will be where you place your main data to be rendered in your HTML templates.

  1. Finally, we’ll define a render method that calls the j2.Template with our class and render out a template to return

Note: We also need to set the arbitrary_types_allowed = True to enabled us to use types like Request and Jinja2Templates.

from pydantic import BaseModel, Field
from fastapi.templating import Jinja2Templates
from fastapi import Request

@lru_cache
def use_j2_templates() -> Jinja2Templates:
    return Jinja2Templates(app_dirs.TEMPLATES)

class Jinja2Response(BaseModel):
    j2: Jinja2Templates = Field(default_factory=use_j2_templates)

    # Template Vars
    html: str
    css: Optional[list[str]]
    js: Optional[list[str]]

    request: Request
    title: str = ""
    data: dict = {}

    class Config:
        arbitrary_types_allowed = True

    async def render(self) -> str:
        return self.j2.TemplateResponse(self.html, self.dict())

Automatically Injecting CSS/JS

Sometimes you will want to extend a page by adding JavaScript or CSS. This can be cumbersome to track and organize, but you can use a helper function in conjunction with the class above to automatically inject assets by using a standard organization for code.

Using the function below we’ll pass in the path to the HTML template we’re going to render and the file extension of the assets we’re looking for and it will return any assets matching the path and extension.

For example. If we were to pass in home.html and .js this function would return all JavaScript assets located in pages/home/.

def use_auto_assets(path: str, ext=".js") -> list[str]:
	static = Path("static")

    asset_path = Path("static/pages" / path.removesuffix(".html")

    if not asset_path.exists():
        return []

    else:
        return [
        "/" + str(x.relative_to(static)) for x in
        asset_path.glob("**/*" + ext) if x.is_file()
        ]

Now we can easily add 2 validators to our Pydantic model above to automatically inject the CSS and JavaScript whenever a template is rendered

    @validator("css", always=True)
    @classmethod
    def css_validator(cls, css: list = None, values: dict = None) -> list:
        base_css = [static_files.Css.base] + use_auto_assets(values.get("html"), ".css")
        if not css:
            return base_css
        else:
            return base_css + css

    @validator("js", always=True)
    @classmethod
    def js_validator(cls, js: list = None, values: dict = None) -> list:
        base_js = [static_files.Js.base, static_files.Js.alpine_min] + use_auto_assets(values.get("html"), ".js")
        if not js:
            return base_js
        else:
            return base_js + js

All Together

All together your Jinja2 Class should look something like this.

class Jinja2Response(BaseModel):
    j2: Jinja2Templates = Field(default_factory=use_j2_templates)

    # Template Vars
    html: str
    css: Optional[list[str]]
    js: Optional[list[str]]

    request: Request
    title: str = ""
    data: dict = {}

    @validator("css", always=True)
    @classmethod
    def css_validator(cls, css: list = None, values: dict = None) -> list:
        base_css = [static_files.Css.base] + use_auto_assets(values.get("html"), ".css")
        if not css:
            return base_css
        else:
            return base_css + css

    @validator("js", always=True)
    @classmethod
    def js_validator(cls, js: list = None, values: dict = None) -> list:
        base_js = [static_files.Js.base, static_files.Js.alpine_min] + use_auto_assets(values.get("html"), ".js")
        if not js:
            return base_js
        else:
            return base_js + js

    @validator("request", pre=True, always=True)
    @classmethod
    def request_must_be_valid(cls, v):
        if not isinstance(v, Request):
            raise ValueError("must contain a space")
        return v

    async def render(self) -> str:
        return self.j2.TemplateResponse(self.html, self.dict())

    class Config:
        arbitrary_types_allowed = True

The most important part about this is standardizing how you return your responses in a way that is extensible and easy to use. Now, later down the road when you need to add some functionality or refactor some code you can easily adjust your model to reflect those changes.

In a future post, I’ll be talking about how I use code-generation to speed up creating pages, and how to integrate TailwindCSS into your Flask or FastAPI Project.