Flexible components in Eleventy with Nunjucks macros

4th December 2020 1,447 words

Tags

Out of the box, Eleventy gives you a choice of ten different templating languages. Two of these: Nunjucks and Liquid bear more than a passing resemblance to Twig, the templating language used by Craft CMS. When I was migrating this site from Craft to Eleventy, I tried liquid but eventually settled on Nunjucks, simply because it seems to be more popular in the Eleventy community. However, I quickly encountered a problem: include in Nunjucks doesn’t work like include in Twig. Whereas the following code works fine in Twig, Nunjucks doesn’t support passing variables to includes:

{# Works in twig, doesn’t work in nunjucks #}
{% include 'template.html' with {'foo': 'bar'} %}

Fortunately, there is a solution and it comes in the form of macros: one of Nunjucks’ most powerful features. I like to think of macros like React function components. Here are a few ways in which they are similar:

  1. Like in JavaScript, imported macros have a separate scope, meaning they don’t have access to any variables from the parent.[1]
  2. If you want to use variables from outside, you need to explicitly pass them in.
  3. You can set default values for props/arguments to be used if they are not defined.

At first glance, some of these might seem like downsides, but if you’re used to React then you can probably see how they might help to make your code cleaner and reduce bugs.

An example component using macro

Let’s take the following example of the postCard component, used on this site for showing a summary of each blog post, and break it down. Here is the template, src/_includes/macros/post-card.njk:

{% from 'macros/tags.njk' import tagsList %}

{% macro postCard(post, class = 'w-full', cardClass = '', element = 'div', headingElement = 'h2') %}
<{{ element }} class="{{ class }}">
<div class="
{{ cardClass }}">
<
{{ headingElement }}>
<a href="
{{ post.url or post.data.linkUrl }}">
<span>
{{ post.data.title }}</span>
</a>
</
{{ headingElement }}>
<time datetime="
{{ post.date | w3date }}">{{ post.date | date }}</time>
<p>
{{ post.data.metaDesc }}</p>
{% if post.data.tags | length %}
{{ tagsList(post.data.tags) }}
{% endif %}
</div>
</
{{ element }}>
{% endmacro %}

And here is how we include it in our blog listing template, src/_includes/layouts/blog.njk:

{% from 'macros/post-card.njk' import postCard %}

<ol reversed="reversed">
{% for post in collections.blog %}
{{ postCard(post, element = 'li', class = '') }}
{% endfor %}
</ol>

When calling a macro, you can pass arguments in the order they are defined in the macro (post is first in this case), use their name (as we’ve done here for element and class), or a mix of the two. All arguments not passed will use the default if set.

Import statement

{% from 'macros/tags.njk' import tagsList %}

The first line in our macro file is an import statement. Because we want to use the tagsList component to show a list of tags on each card, we need to import it. This works similarly to a JavaScript import statement: like JavaScript functions, if you want to use a macro from outside the current file, you’ll need to import it or else you’ll get an error. Note that in Eleventy, the file path is relative to the root of your templates directory (which for me is src/_includes). You can also use relative paths: ./ for the template’s directory, or ../ for the template’s parent directory.

Passing arguments

{% macro postCard(post, class = 'w-full', cardClass = '', element = 'div', headingElement = 'h2') %}

Next up, we have the opening macro tag. Here we declare the name of the macro and the arguments. post is the only one of these variables without a default value – that’s because the post card is meaningless without post data. The rest of the arguments are optional, so we define default values.

If you’re familiar with React, this is equivalent to the following code (we’re destructuring the props object here and setting some default values – you could also define default prop values with prop-types):

const PostCard = ({
post,
class = 'w-full',
cardClass = '',
element = 'div',
headingElement = 'h2'
}) => {
// rest of the component here…

Contextual heading levels

The headingElement variable is used so we can be more flexible about where the component appears and make sure it correctly fits in with the heading hierarchy[2]. For example on the homepage we call our postCard macro like this:

{{ postCard(post, class = 'mt-0', cardClass = 'max-w-xs', element = 'li', headingElement = 'h3') }}

Notice how the headingElement is set to 'h3' instead of the default of 'h2'. This is because the parent heading 'Posts' is an <h2>. Compare this with the blog archive page – here the parent heading is 'Blog', an <h1>, so we can let the macro fall back to using the default value: 'h2':

{{ postCard(item, element = 'li', class = '') }}

The rest of the template should be pretty familiar if you’ve used Nunjucks before. We’re using the arguments passed to the macro the same we would any other template variable.

An alternative using include

If, instead of a macro, I was to use an include for my postCard example, it would look something like this:

src/_includes/partials/post-card.njk

{# Set some default values #}
{% set class = class if class else 'w-full' %}
{% set cardClass = cardClass if cardClass else '' %}
{% set element = element if element else 'div' %}
{% set headingElement = headingElement if headingElement else 'h2' %}

<{{ element }} class="{{ class }}">
{# The rest of the component is omitted as it’s the same as the macro example #}
</{{ element }}>

Here we’re using Nunjucks’ rather wonky looking ternary syntax to set some default values. This is equivalent to let class = class ? class : 'w-full' in JavaScript.[3]

src/_includes/layouts/archive.njk

<ol reversed="reversed">
{% for post in collections.blog %}
{% set element = 'li' %}
{% set class = '' %}
{% include "partials/post-card.njk" %}
{% endfor %}
</ol>

As mentioned before, Nunjucks doesn’t let you pass variables along with include. You need to define them beforehand. Here we’re setting the post variable as we loop through items in the collection and setting two more variables, element and class. These will all be accessible inside the post-card.njk partial.

Another example

One benefit of macros is that they avoid problems where inherited variables either need to be reset, or cause unexpected consequences. But if you’re aware of the risks and want even more flexibility, you can give them access to the parent context using with context. Consider the following example using include; here we have two calls-to-action – the first comes from markdown Front Matter and the second from a global data file (located at src/data/globalCta.json):

src/_includes/partials/cta.njk

{# The variable 'globalCta' comes from global data. This is the default value for cta #}
{% set cta = globalCta %}

{# Use ctaContent if defined #}
{% if ctaContent %}
{% set cta = ctaContent %}
{% endif %}

<a href="{{ cta.linkUrl }}">{{ cta.linkText }}</a>

src/_includes/layouts/home.njk

{# Use data from 'pageCta' defined in the Front Matter #}
{% set ctaContent = pageCta %}
{% include "partials/cta.njk" %}

{# Reset ctaContent back to its default #}
{% set ctaContent = globalCta %}
{% include "partials/cta.njk" %}

Notice that we need to reset a variable back to its default value; if we didn’t, the second call-to-action would also use pageCta. With a macro this resetting wouldn’t be necessary — we’d set a default value for cta of globalCta (this only works because we’re importing the macro with context) and we could replace the whole of our cta.njk file with the following:

{% macro cta(cta = globalCta) %}
<a href="{{ cta.linkUrl }}">{{ cta.linkText }}</a>
{% endmacro %}

This also allows us to simplify the code in our home.njk template. Note how we’ve added with context to our import statement, which allows us to use the global data file, globalCta.json inside our macro:

{% from 'macros/cta.njk' import cta with context %}

{{ cta(pageCta) }}
{{ cta() }}

Conclusion

I’m sure that a lot of my reasoning for using macros over includes is personal preference — the mental model of scoped function components is something I’m familiar with from React.

Neither includes or macros are perfect for every situation. I still use includes for simpler components — things like the site header and footer — but the isolated, flexible nature of macros make them ideal for building complex, maintainable UIs. If you’re building a library of reuseable components for your Eleventy site, give macros a try.


  1. I know this isn’t always the case when using var, but it is if you’re following the modern ‘best practice’ of using let and const to declare variables. ↩︎

  2. Heydon Pickering gives a comprehensive overview of the problem of handling heading levels in design systems in the article, Managing Heading Levels In Design Systems. ↩︎

  3. Or let class = class || 'w-full' if you want it even more concise. ↩︎