Components using Jinja macros with TailwindCSS

  • Posted: March 11, 2023
  • Updated: March 11, 2023

I’ve been doing web development for several years now and have come up with a frontend development pattern I quite like. So, here I am sharing it. To preface this, I want to say that I have a general distaste towards large amounts of JavaScript for its impacts on web security, privacy, and accessibility, its poor packaging and dependency situation, but mainly because I just haven’t used it all that much. So, my solution will avoid having a strict dependency on JavaScript.

I generally use Nginx as a reverse proxy in front of a Python backend using the Django framework, backed by a PostgreSQL database and, sometimes, a Redis cache. These are there to build the backend of my websites, have a place to store long-lived and ephemral data, and serve assets like images, videos, and the small amounts of JavaScript I do use. All of these tools can be replaced with your preferred alternatives. Now come the core components of this pattern: my preferred templating engine, Jinja, and the Tailwind CSS framework. You’ll see why I chose Jinja below, but the reason I chose Tailwind was because of its utility-first design.1

The utility-first design allows for easy customization where necessary and places all styling within the markup, however, without a component system like that of React, I would end up with very repetitive HTML. Let’s work with an example of a frequently asked question section on a page:

<div class="mb-4 px-4">
  <h1 class="text-lg text-gray-900 font-bold">How do I do X?</h1>
  <p class="text-md text-gray-600 font-normal">
    Simple! You just have to foo bar.
  </p>
</div>
<div class="mb-4 px-4">
  <h1 class="text-lg text-gray-900 font-bold">How do I do Y?</h1>
  <p class="text-md text-gray-600 font-normal">
    Difficult! You would have to bar foo.
  </p>
</div>
<!-- More questions -->

That would be a lot of places to change if I wanted to change the font colours, let alone add new tags. One suggestion might be to provide data as part of the rendering context and use a for loop as such:

{% for title, answer in data %}
  <div class="mb-4 px-4">
    <h1 class="text-lg text-gray-900 font-bold">{{ title }}</h1>
    <p class="text-md text-gray-600 font-normal">
      {{ answer }}
    </p>
  </div>
{% endmacro %}

…but this would mix content and code: not ideal.2 Sure, you could store this data elsewhere, but that increases complexity

Instead, let’s use Jinja macros! A macro is essentially a function inside of HTML which can be called with parameters.

{% macro question(title, answer) %}
  <div class="mb-4 px-4">
    <h1 class="text-lg text-gray-900 font-bold">{{ title }}</h1>
    <p class="text-md text-gray-600 font-normal">
      {{ answer }}
    </p>
  </div>
{% endmacro %}

{{ question("How do I do X?", "Simple! You just have to foo bar.") }}
{{ question("How do I do Y?", "Difficult! You would have to bar foo.") }}
{# More questions. #}

Now you may be asking, what do I do if I want to nest macros or the parameter is a very long string? Instead of using the macro directly, you can instead call the macro, which lets you use it as a block. The contents of the block can be accessed using the caller function within the macro as such:

{% macro question(title) %}
  <div class="mb-4 px-4">
    <h1 class="text-lg text-gray-900 font-bold">{{ title }}</h1>
    <p class="text-md text-gray-600 font-normal">
      {{ caller() }}
    </p>
  </div>
{% endmacro %}

{% call question("How do I do X?") %}
  Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy
  eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam
  voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet
  clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit.
{% endcall %}
{# More questions. #}

For text-based sections, I sometimes opt to use Markdown within the call block and render it with a markdown function I write as such: {{ markdown(caller()) }}. One other particularly clever trick you can do is return values, including other macros, back to the caller. This can be useful for restricting macros to be used only within certain contexts or to be able to use share names where you otherwise couldn’t. Of course, you should use this feature conservatively:

{% macro faq_section(header) %}
  {% macro question(title, answer) %}
    <div class="mb-4 px-4">
      <h2 class="text-lg text-gray-900 font-bold">{{ title }}</h2>
      <p class="text-md text-gray-600 font-normal">
        {{ answer }}
      </p>
    </div>
  {% endmacro %}

  <div class="pb-4">
    <h1 class="text-xl">{{ header }}</h1>
    {{ caller(question) }}
  </div>
{% endmacro %}

{% call(question) faq_section("Account Questions") %}
  {{ question("How do I do X?", "Simple! You just have to foo bar.") }}
  {# More questions. #}
{% endcall %}

{# This call would be undefined. #}
{{ question("How do you Y?", "You don't.") }}

Finally, for commonly used macros, I like to put them in a separate file called components.jinja and import them in a base template with:

{% from "components.jinja" import foo, bar, ... %}

This setup has proven very useful for minimizing repetitive code and increasing readability by decreasing nesting. It also makes it easy to make changes and, when combined with the other basic Jinja features of extending and importing templates, has proven very quick to work with. Starting up a project with this pattern is also easy with a minimal configuration involving only a basic Django project, maybe a couple settings changes, and the inclusion of the TailwindCSS library.3

I should also point out that if you prefer using a CSS framework which is component-based instead of utility-based, you can still leverage macros to your advantage. You also don’t need to start from scratch to use macros because they are entirely optional and generally self-contained. You can go through your HTML and simplify small parts at a time, building towards complete conversion.

Good luck in your future projects, and may these ideas prove useful!


  1. The Utility-First Fundamentals page on their site describes this and some of the other benefits I find compelling. ↩︎

  2. To be clear, my stance on this is of course more nuanced. I think it’s okay to place, for instance, error strings in the backend which will get rendered in the frontend. However, in this case, you may end up storing far more content, like paragraphs of text, together with code. ↩︎

  3. Production deployment will of course be more complicated, but most of the changes actually involve Django, and the rest of the backend. The only change I make on the frontend is switching out the full TailwindCSS library with a PostCSS setup that only includes the CSS classes I use. ↩︎