Welcome to the Templating Guide
Here we'll explore how to use Handlebars and Nunjucks in various environments: Ghost, Metalsmith, Eleventy, and Next.js. This guide covers how to create reusable templates, manage data and even extend the functionality of these engines to fit specific needs.
It is quite a common occurrence in programming that one needs to maintain a structure in code, while changing content found throughout it.
Imagine one has a recipe for a nice cake. A template is akin to such a recipe: it possesses a structure and placeholders (exempli gratia, add __ eggs, bake for __ minutes).
A templating language, such as Handlebars, fulfills the recipe role for HTML. It enables one to compose a base HTML structure and subsequently inject dynamic data therein.
In other words, Handlebars functions as a bridge,
retrieving content from
various sources (such as a database or
.md files for blog posts) and
integrating it into the HTML structure.
Fundamentally, this process involves: setting data somewhere.
Templating with Ghost
One might consider using a VPS for self-hosting a Ghost instance. However, it is also pertinent to note the alternative of employing a JAMstack approach, for which ample information and resources are available online, specifically regarding Ghost.
The latter option, however, presents a challenge: many of Ghost's inherent strengths (subscriptions, user content management, mail forwarding for updates, post visit tracking; all managed via a good UI) would necessitate integration from scratch, or through separate third-party services if Ghost were to operate headless with an SSG.
Should the reader require a general overview of SSGs without the particularities of Ghost, please navigate to the other platform sections via the sidebar. The decision to commence with Ghost stems from its relative flexibility and comprehensive feature set concerning custom Handlebars helpers, even if it could be considered bloated for certain applications.
The installation process will be omitted for brevity, just procure the prerequisite programs and correct version of Node.js. Do note that building a Ghost instance does take a good amount of time; even to start a development server is too lenghty a time for my liking. There also is no HMR, having one to manually refresh the page after file changes and rebuilds.
Templating proves ideal for a CMS platform like Ghost, where content (posts, pages, and general data) is separate from the overall design, yet requires periodic addition or modification.
A "Ghost theme" comprises a collection of
.hbs files, which are essentially
HTML files, plus Handlebars expressions for Ghost's
particular implementation of the language.
The most important files in a Ghost theme are:
-
index.hbs: Fallback for{tag,author,default}.hbs. Extendsdefault.hbs. If you lack one, Ghost will set one regardless. -
default.hbs: The main layout file. The frame for the entire site (header, main, footer, etcetera). One could just useindex.hbsfor this, as the concept is similar. -
post.hbs: Displays a single post.
Basic Syntax: Expressions
Handlebars expressions are straightforward, encapsulated within
double curly braces: {{expression}}.
Ghost supplies the
data from its own database
edited through its web UI at
https://WEBSITE_URL/ghost, while as Handlebars
renders it in the expression's place.
For instance, within the post.hbs file, one could
define a document title as follows:
<title>{{title}}</title>
Upon a user's page view, Ghost will have replaced
{{title}} with the actual title of that post, as set
by an administrator of a Ghost instance for any particular post.
Understanding Contexts
The data accessible to Handlebars expressions varies depending on the page being viewed. This phenomenon is termed "context." For example:
-
On a single post page (
post.hbs), one has thepostcontext. This grants access to all data pertinent to that specific post, such as{{title}},{{content}}and{{published_at}}. -
On the homepage (
index.hbsordefault.hbs), one operates within theindexcontext. Here, individual post data is not directly available; instead, a collection of all posts is provided and could be looped through.
A thorough understanding of context is paramount for discerning which data works at which parts of the Ghost theme. Sometimes, mostly with custom routing on complex themes, it could get confusing or seem broken.
Ghost Helpers
Helpers are JavaScript functions embedded within templates. Their primary purpose is to facilitate logical operations within a logic-less templating language such as Handlebars.
Ghost provides several custom template types by default. May you think of them like complex snippets evoked by simple keywords.
A non-exhaustive enumeration of these helpers begins in the next section. The definition of custom helpers and/or altrernatives will be addressed later.
Data Helpers
This category represents the most frequently employed helpers for outputting content:
-
{{title}}: Title of the post. -
{{content}}: Displays the complete content of the post, often formatted within<p></p>tags, thereby including paragraph breaks on newlines. It could even include a CTA to sign up or upgrade for determinate users if configuration is set for restricted and/or limited access. -
{{excerpt}}: Provides a shortened version of the post's content. -
{{author.name}}: Outputs the name of the post's author, corresponding to a Ghost user. -
{{tags}}: Presents a literal list of tags associated with the post (e.g., news, technology, entertainment), which may be iterated through.
Functional Helpers
Ghost additionally furnishes more advanced helpers, enabling greater control over template logic.
The {{#foreach}} Helper
In the homepage, or wherever the post context is defined within
routes.yaml, one will most likely need to iterate
through the published posts. The {{#foreach}} helper
is ideal for this.
{{#foreach posts}}
<article>
<h2>{{title}}</h2>
<p>{{excerpt}}</p>
<a href="{{url}}">Open post itself!</a>
</article>
{{/foreach}}
It is important to note that the link to the post itself opens in
post.hbs.
Let us see a simple example of such a file, where the excerpt is rather the full content, and there also is an image from the Ghost admin panel.
post.hbs
<div>
<h1>{{title}}</h1>
<p>{{content}}</p>
<img src="{{img_url feature_image size="big" format="webp"}}" />
</div>
The {{#post}} Helper for Accessing
post Context
When operating within the post context on a dedicated
post.hbs page, one merely and practically requires
{{content}} to retrieve the post's content. The
{{#post}}...{{/post}} block is implicitly available
in this scenario.
However, note that the
documentation
uses the {{#post}} helper to make explicit that we
are "dropping into" such context.
The technically correct way to access this
data would be to write:
{{post.title}}, {{post.content}}, etc.,
even if it works either way.
I do suggest to follow the documentation and access the
post context within a
{{#post}}...{{/post}} block.
{{#post}}
{{!-- rest of ... --}}
<p>{{content}}</p>
{{!-- ... the post --}}
{{/post}}
Just as when at the index context we have a list of
posts to loop through with the use of the
{{#foreach}} helper, the post context
provides a more particular one.
What if we are on any other page, such as
page.hbs or similar?
The {{#get}} Helper
The {{#get}} helper is a more widely-encompassing
instrument for retrieving
data not available in the
current context.
For example, one could fetch the three most recent posts
associated with a specific tag from any
.hbs file within the theme.
{{#get "posts" filter="tags:specific-tag" limit="3"}}
{{#foreach posts}}
<h2>{{title}}</h2>
{{/foreach}}
{{/get}}
The {{#if}} Helper
The {{#if}} helper enables conditional rendering of
HTML blocks. This is particularly useful, for
instance, when verifying the existence of a feature image prior to
attempting its display.
{{#if feature_image}}
<img src="{{img_url feature_image}}" alt="{{title}}">
{{else}}
{{!-- Do a barrel roll! (Z or R twice) --}}
{{/if}}
Keeping in mind that {{feature_image}} is part of the
data Ghost provides within
the post context.
Adding Extra Functionality to Ghost
It is pertinent to acknowledge the potential limitations of Ghost
without the provision of custom fields for posts. While the
platform offers extensive customization and openness compared to
many counterparts, one is restricted to
{{title}}, {{content}}, and
tags for dynamic
data assignment.
An alternative approach involves employing a script with its type
attribute set to "application/json", containing
data fetched from the Ghost
admin via helpers. This data can then be parsed by a
JS
script to programmatically create elements and assign their
textContent, with organization facilitated by
tags. As previously mentioned, these posts
accommodate only two text inputs: title and content; therefore, we
would at most have two fields per tag.
Here, we construct a JSON array of objects for each post having a specific tag, also incorporating all other tags for extra data:
{{!-- Fetch posts tagged as "custom" into a JSON array of objects --}}
{{!-- Include their comma-separated tags for further use as well --}}
{{#get "posts" filter="tag:custom" include="tags"}}
<script type="application/json" id="post-data">
[
{{#foreach posts}}
{
"title": "{{title}}",
"content": "{{content}}",
"tags": [
{{#foreach tags}}
"{{slug}}"{{#unless @last}},{{/unless}}
{{/foreach}}
]
}{{#unless @last}},{{/unless}}
{{/foreach}}
]
</script>
{{/get}}
Observe how the {{#unless}} helper facilitates the
proper comma separation of each object, resulting in valid JSON.
The array of tags is delimited the same.
Regarding the {{slug}} helper, it is used to output a
URL-safe version of the tags. One could also use
{{name}} to retrieve the tag precisely as entered in
the Ghost admin panel irrespective of URL-friendliness, or even
{{url}}, which is still less suitable in this
example.
const dataElement = document.getElementById("post-data");
const postData = JSON.parse(dataElement.textContent);
// Once the data is parsed, one can access postData.title, etc.
postData.forEach((post) => {
const divElement = document.createElement("div");
// Ascertain if the post contains a specific tag.
const hasSpecificTag = post.tags.includes("specific-tag");
divElement.textContent = hasSpecificTag ? post.title : post.content;
document.body.appendChild(divElement);
});
It is noteworthy that tags are what makes
customization possible here.
Utility Helpers
This last type are very nice features!
-
{{asset "sub-dir/file.ext"}}: A crucial one for linking to the theme's CSS, JS, or any other static asset, such as images within theassets/directory, providing cache functionality. -
{{pagination}}: Generates formatted HTML for pagination links, which are customizable inpagination.hbs. This implies one would limit the ammount of posts shown per page and be within thepostcontext. It does not work when using the{{#get}}helper (in my experience). -
{{search}}: Renders a functional, pre-styled search button and its icon.
Custom Helpers: Are They Possible?
These are not officially supported by Ghost.
Should one genuinely require custom helpers, although more robust (and safer) alternatives are presented shortly, one could either modify Ghost's core files (which would get removed upon updating Ghost) or implement a reverse proxy with middleware, as shown here.
Officially Supported Alternatives
It is entirely feasible to implement extra functionality without registering new helpers:
- One may leverage client-side JavaScript, as previously illustrated.
- Another related option involves the Content API, the suitability of which is contingent upon the specific requirements of one's application.
For the majority of use cases, the built-in helpers suffice. However, it is important to note that Handlebars' inherent logic-less nature could be an issue. The second major section of this website will pivot to Nunjucks to address this same limitation.
Structuring Themes with Partials
Partials are modular, reusable segments of a template. Their utility lies in promoting the DRY principle. For example, one could create a partial dedicated to post meta-information (e.g., author, date, tags).
To implement, one could create a template file within the
partials/ directory and subsequently incorporate it
into another template using the following syntax:
{{> "post-card"}}
/partials/post-card.hbs
<div class="card">
<h2>Hello from Somewhere</h2>
<p>
The weather is great and the views are amazing.
Sending you a little postcard through the web.
</p>
<p class="signature">
— Yours Truly.
</p>
</div>
Templates not only are reusable pieces of HTML inside a Handlebars file, as one could also use helpers inside them. I omit this in this example.
Routing and Layouts
In Ghost, the default.hbs file functions as the
primary template for one's site. It typically incorporates the
header, footer and other elements common to all pages. The
{{{body}}} helper serves as a specialized placeholder
that instructs Ghost where to inject the content of the
current page.
For instance, when a user accesses a single post, Ghost will
render the
post.hbs template and embed its content into the
{{{body}}} of the default.hbs file. This
mechanism ensures a consistent layout across the entire site.
Ghost's routing system facilitates the creation of custom URLs and
their mapping to specific templates. This configuration is managed
within a
routes.yaml file, which can be uploaded via the Ghost
admin panel. As an example, one could establish a static page
featuring an "easter egg" by defining a custom route.
routes.yamlroutes:
/easter-egg/:
template: extra-page
This configuration directs Ghost to render the
extra-page.hbs template when a user navigates to the
/easter-egg path.
extra-page.hbs{{!< default}}
<div class="easter-egg">
<p>Here is the easter egg!</p>
</div>
{{!< default}} sends our page to
default.hbs, placing it at {{{body}}}.
One-off templates
One is able to create custom routing by adding the slug of a page
to a template file. This necessitates no actual routing in
routes.yaml. Some examples, including their
URL path:
-
page-about.hbs: Custom template for anaboutpage at/about. -
tag-news.hbs: Custom template for anewsarchival page at/tag/news. -
author-staff.hbs: Custom template for a staff member at/author/staff. This could be used to add a special UI badge, for instance.
Complete Example: A Feature-Rich Homepage
This section elucidates the integration of previous concepts. The example will encompass partials for the header, footer and post cards, leveraging built-in Ghost helpers to construct a comprehensive layout.
Creating Partials within partials/
Several partials will be created to maintain code organization and reusability.
partials/header.hbs<header class="site-header">
<div class="site-header-content">
<h1 class="site-title">{{@site.title}}</h1>
<p class="site-description">{{@site.description}}</p>
</div>
</header>
Data helpers starting with @ indicate
global
data is provided, available
anywhere within the theme.
partials/post-card.hbs<article class="post-card">
{{#if feature_image}}
<a class="post-card-image-link" href="{{url}}">
<img class="post-card-image" src="{{img_url feature_image}}" alt="{{title}}" />
</a>
{{/if}}
<div class="post-card-content">
<a class="post-card-content-link" href="{{url}}">
<header class="post-card-header">
<h2 class="post-card-title">{{title}}</h2>
</header>
<section class="post-card-excerpt">
<p>{{excerpt}}</p>
</section>
</a>
<footer class="post-card-meta">
<span class="post-card-author">{{author.name}}</span>
</footer>
</div>
</article>
partials/footer.hbs<footer class="site-footer">
<a class="site-footer-link" href="{{@site.url}}">{{@site.title}}</a>
© {{date format="YYYY"}} — All rights reserved.
</footer>
We are at the index, homepage or root
This content will be outputted at the
{{{body}}} helper.
index.hbs{{!< default}}
{{#foreach posts}}
{{> "post-card"}}
{{/foreach}}
Here is the conclusive example of the file where everything goes:
default.hbs<!doctype html>
<html>
<head>
<title>{{@site.title}}</title>
<link rel="stylesheet" type="text/css" href="{{asset "css/style.css"}}" />
</head>
<body>
{{> "header"}}
{{{body}}}
<a href="/easter-egg">Where the routing goes</a>
{{> "footer"}}
</body>
</html>
Analysis of the Homepage Example
-
Inclusion of Header and Footer: It incorporates
{{> "header"}}and{{> "footer"}}to integrate the respective partials. -
Iteration through Posts: The
{{#foreach posts}} ... {{/foreach}}block iterates over the collection of posts provided by Ghost, which are typically authored by an administrator within the Admin panel. -
Rendering of Post Cards: Within the loop,
{{> "post-card"}}is invoked for each post. The context inside the partial is programmatically set to the current post in the iteration, thereby ensuring correct functionality of helpers such as{{title}}and{{excerpt}}.
Introduction to Metalsmith
Metalsmith is an exceptionally straightforward, pluggable SSG. Its distinctive characteristic lies in its core philosophy: every component is a plugin.
Unlike other SSGs that offer a fixed set of features, Metalsmith provides a minimalist framework, delegating further functionality to its plugin ecosystem.
The Core Pipeline
Metalsmith operates on a fundamental, three-step pipeline.
-
Read: Ingests all files from a designated
source directory (e.g.,
src/) and loads them into memory. Each file is represented as a JavaScript object containing its main data and associated metadata. - Process: This collection of file objects then undergoes a series of transformations via plugins. Each plugin possesses the capability to manipulate the files by modifying their content or adding and removing files from the collection.
-
Write: Upon completion of all plugin
operations, Metalsmith outputs the resulting files to a
specified destination directory (e.g.,
build/).
The Efficacy of Metadata
Metadata is handled in quite neat a manner in our upcoming examples: when Metalsmith processes a file, it parses any front-matter (id est, YAML at the apex of a Markdown file) and exposes it as an associated metadata object. This metadata is then propagated through the plugin chain.
For example, a Markdown plugin would read the raw content of a
file, convert it to HTML, and update the file
object's contents property. A layouts plugin would
then receive that HTML content and inject it into a Handlebars
template, utilizing the file's metadata to populate variables such
as {{title}} or {{author}}.
Project Structure
A typical directory structure for a Metalsmith project provides a clear separation of concerns and facilitates a clean build process.
.
├── build/
├── layouts/
│ ├── post.hbs
│ └── index.hbs
├── src/
│ ├── posts/
│ │ ├── first-post.md
│ │ ├── second-post.md
│ │ └── third-post.md
│ └── index.md
└── metalsmith.js
In case you would like to follow along:
mkdir -p layouts src/posts
-
build/: The destination for the built site. It is created by Metalsmith and contains the final HTML files and any other assets. -
layouts/: It holds Handlebars templates; these define the structure and layout of different pages. -
src/: The source directory for the content. Metalsmith reads all files from here to begin the build process. -
metalsmith.js: Build script where the Metalsmith pipeline is defined and plugins are configured.
Metalsmith Setup
The setup for a blog involves installing the core Metalsmith library along with a few key plugins:
npm install metalsmith \
handlebars \
@metalsmith/collections \
@metalsmith/markdown \
@metalsmith/permalinks \
@metalsmith/layouts \
jstransformer-handlebars
Here is a concise overview of these packages.
metalsmith: Core Metalsmith library.handlebars: Handlebars templating engine.-
@metalsmith/collections: Groups files together, ideal for creating a list of blog posts. -
@metalsmith/markdown: Converts Markdown files into HTML. -
@metalsmith/permalinks: Creates clean, user-friendly URLs for your posts. -
@metalsmith/layouts: Plugin to apply Handlebars templates to a given content. -
jstransformer-handlebars: Dependency of@metalsmith/layouts. See the official registry's reference on how the plugin works withtransformersand they "should be installed separately".
The Build Script
The build script is where to define the Metalsmith pipeline. It chains together all the plugins to transform source files into a website in this case, of whatever the output as defined.
metalsmith.js
// Require the necessary modules
const Metalsmith = require('metalsmith');
const collections = require('@metalsmith/collections');
const markdown = require('@metalsmith/markdown');
const permalinks = require('@metalsmith/permalinks');
const layouts = require('@metalsmith/layouts');
// Initialize Metalsmith in the current directory
Metalsmith(__dirname)
// Clean the build directory before starting
.clean(true)
// Specify the source and destination directories
.source('./src')
.destination('./build')
// Add global metadata, available in all templates
.metadata({
sitename: "My Simple Metalsmith Blog",
siteurl: "https://example.com/",
})
// Group all markdown files in the 'posts' directory into a 'posts' collection
.use(collections({
posts: 'posts/*.md'
}))
// Convert all markdown files to HTML
.use(markdown())
// Apply clean and user-friendly URLs
.use(
permalinks([
{
pattern: ':collection/:title',
collection: 'posts'
}
])
)
// Apply the Handlebars layouts to the HTML files
.use(layouts({
engine: 'handlebars',
directory: 'layouts',
transform: 'jstransformer-handlebars',
pattern: "**/*.html"
}))
// Build the site
.build(function (err) {
if (err) throw err;
console.log('Site built successfully!');
});
Running the Build
To build the site, simply execute the build script from a terminal:
node metalsmith.js
After the script runs, you will see a "Site built successfully!" message, and your build/ directory will be
populated with the generated HTML files, ready to be
deployed.
Creating a Blog
With the build process in place, we shall create a simple blog. This involves creating content with metadata, defining layouts to display that content and using collections to create an index page (for posts).
Rich Metadata in Posts
Create a Markdown file for a blog post in src/posts/.
Use YAML front-matter to add metadata such as title,
author, date and the layout to use.
src/posts/first-post.md---
title: First Post
author: Yours Truly
date: 2026-1-07
layout: post.hbs
---
This post is written in Markdown as of yet, yet not forever!
The Post Layout
Create a layout in layouts/post.hbs to render the
individual blog posts. This template will display the metadata and
the post content.
layouts/post.hbs<!doctype html>
<html>
<head>
<title>{{ sitename }} - {{ title }}</title>
</head>
<body>
<article>
<h1>{{ title }}</h1>
<p>By {{ author }} on {{ date }}</p>
<div>
{{{ content }}}
</div>
</article>
</body>
</html>
The Index Page and Collections
The @metalsmith/collections plugin groups all your
posts into a posts collection just as we defined it
to do in the build script.
You can then loop over this collection in a layout to create an index page that lists all your posts.
First, create the source file for the index page.
src/index.md---
title: Home
layout: index.hbs
---
Welcome to my new blog!
Now, create the corresponding layout that iterates over the
posts collection. We output a list item per post.
layouts/index.hbs<!doctype html>
<html>
<head>
<title>{{ sitename }}</title>
</head>
<body>
<h1>{{ sitename }}</h1>
{{{contents}}}
<h2>Posts:</h2>
<ul>
{{#each collections.posts}}
<li>
<a href="/{{ this.permalink }}/">{{ this.title }}</a>
</li>
{{/each}}
</ul>
</body>
</html>
That covers the basics for a reliable blog-creation workflow. If you were to build the site at this stage (granted the steps above) it would be crude HTML, yet I hope the reader sees how practical templating is on itself.
One could test it from the build/ directory using a
development server like
live-server.
All that would be left to do is create better layouts and add classes; that part escapes the scope of this documentation.
Mentioning Extra Plugins
Metalsmith facilitates a high degree of customization. The following are illustrative examples of achievable functionalities:
-
Drafts: The
@metalsmith/draftsplugin can be utilized to conceal posts that are not yet prepared for publication. -
RSS Feeds: The
@metalsmith/rssplugin is capable of generating an RSS feed for one's blog. -
Sass/Less: Pre-process CSS with
plugins such as
@metalsmith/sassor@metalsmith/less.
Creating a Custom Plugin
For more advanced use cases, you can create your own Metalsmith plugins.
A plugin is a JavaScript function that receives the
files object and the
metalsmith instance. It can manipulate the
files
object before passing it to the next plugin in the chain.
Example: A Truncation Plugin
Let us construct a custom plugin designed to truncate the content
of each file to a specified length and add it to the file's
metadata as an excerpt.
truncate.js
function truncate (options) {
// Return the plugin function closure
return function (files, metalsmith, done) {
// Iterate over each file in the collection
Object.keys(files).forEach(function (filename) {
// Retrieve the current file object
const file = files[filename];
// Truncate the file content to the specified length
const excerpt = file.contents.toString().slice(0, options.length);
// Assign the generated excerpt to the file's metadata (plus ellipsis)
file.excerpt = excerpt + '...';
});
// Invoke the callback to signal completion
done();
};
}
module.exports = truncate;
Integrating the Plugin
To use this plugin, require it in your
metalsmith.js file and add it to the pipeline with
.use().
// ... (other requires)
const truncate = require('./truncate.js'); // Require your custom plugin
Metalsmith(__dirname)
// ... (other configuration)
.use(truncate({ length: 10 })) // Use the plugin before converting to HTML
.use(markdown())
// Otherwise we would include markup in our text string by now
// ...
.use(layouts({
// ...
}))
.build(function (err) {
if (err) throw err;
});
Adding the Excerpt to a Template
Now that the plugin adds the excerpt to each file's
metadata, one can use it in templates, for instance, on an index
page.
layouts/index.hbs{!-- ... --}
{{#each collections.posts}}
<article>
<h2><a href="/{{ this.permalink }}/">{{ this.title }}</a></h2>
<p>{{ this.excerpt }}</p>
</article>
{{/each}}
{!-- ... --}
Templating with Eleventy
Eleventy, or 11ty, is a modern static site generator designed to be simple and flexible. It supports over ten different templating languages, including Handlebars and Nunjucks.
Its core philosophy is centered around the idea of zero-config to get started, yet it provides a rich set of features for more complex projects. Eleventy works by transforming a directory of templates into a ready-to-deploy static website directory; this being similar to how Metalsmith functions.
Key Features of Eleventy
- Simplicity: Configuration is easy to set up. Numerous templating languages could be used at once in the same project, for instance.
- Extensibility: With a mature plugin ecosystem, one can add features like image optimization, RSS feeds and more.
- Data-Driven: Eleventy's data cascade allows you to pull data from various sources (JSON, JS, front-matter) and make it available to your templates.
Project Structure
Eleventy offers a flexible structure that can be adapted to one's preferences.
.
├── _site/
├── _data/
│ └── global.json
├── _includes/
│ └── base.hbs
├── posts/
│ ├── first-post.md
│ ├── second-post.md
│ └── third-post.md
├── eleventy.config.js
└── index.md
In case you would like to follow along:
mkdir _data _includes posts
-
_site/: Eleventy will create this directory and build the site here. -
_data/: For global data files. Any JSON or JS file here will be available in the templates. -
_includes/: This is where to store reusable template partials, like headers, footers and layouts. -
posts/: A content directory for blog posts, typically written in Markdown. -
index.md: The main entry point which will be processed into the homepage. -
eleventy.config.js: The configuration file for Eleventy, where one can customize settings, add plugins and define custom filters and shortcodes.
Building a Blog with Handlebars
Let us build a simple blog to demonstrate how to use Handlebars with Eleventy.
Setup and Configuration
First, install Eleventy and the Handlebars plugin. As Handlebars is not a default templating engine, we need to add it on its own and return a configuration object that tells 11ty to use it.
npm install @11ty/eleventy \
@11ty/eleventy-plugin-handlebars --save-dev
eleventy.config.js
const handlebarsPlugin = require("@11ty/eleventy-plugin-handlebars");
module.exports = function (eleventyConfig) {
eleventyConfig.addPlugin(handlebarsPlugin);
return {
markdownTemplateEngine: "hbs",
};
};
Creating a Layout
Create a base layout in _includes/. This will serve
as the main template for your pages.
The
{{{ content }}} helper is where the content from
other files will be injected.
_includes/base.hbs
<!doctype html>
<html>
<head>
<title>{{ title }}</title>
</head>
<body>
<main>
{{{ content }}}
</main>
</body>
</html>
Writing Posts
Create posts in the posts/ directory using Markdown.
The front matter at the top of each file provides metadata, such
as title, layout and tags for collections.
posts/first-post.md
---
title: "The First Post"
layout: "base.hbs"
tags: "posts"
---
Welcome to my first post! This is where the content begins...
Using Collections
11ty creates a collection for each tagged directory. You
can access all posts in the posts/ directory through
collections.posts.
Let's create an index page to list them. Do not worry about including templating syntax inside the .MD file.
index.md
---
title: "Homepage"
layout: "base.hbs"
---
<h1>Welcome to the Blog</h1>
<ul>
{{#each collections.posts}}
<li><a href="{{ this.url }}">{{ this.data.title }}</a></li>
{{/each}}
</ul>
Building the project
The npx command is used to build the project.
npx eleventy
Pivoting to Nunjucks
While Handlebars is a great starting point because of its pre-set helpers, Nunjucks offers an objectively better choice for complex sites. Inspired by Python's Jinja2, it introduces features like template inheritance and advanced logic (or just plain, actual logic), absent in Handlebars.
Clearing .hbs related files
Before we begin, it's essential to remove the Handlebars-specific files from our project to avoid conflicts. We'll be creating Nunjucks equivalents for these files.
rm index.md \
_includes/base.hbs \
posts/first-post.md
What could be done, is use one engine for content, yet another for layouts: one could render Markdown while injecting it into a Nunjucks layout (quite common).
There is also template chaining for a given particular file, but we are not even nearly getting into that in this general introduction.
My suggestion after understanding these basics is to work with a starter tempalte that suits the needs of a project.
One thing to note is that 11ty does not delete files from
the _site/ directory when rebuilding, so
first-post.html will still be there although would
not bother for this specific configuration.
Configuration and Setup
We edit the configuration file at the root of the project. This file will define a custom shortcode to display the current year which we will use in our base layout and define the templating engines.
eleventy.config.js
module.exports = function (eleventyConfig) {
eleventyConfig.addShortcode("year", () => `${new Date().getFullYear()}`);
return {
markdownTemplateEngine: "njk",
htmlTemplateEngine: "njk",
};
};
Creating a Base Layout with Blocks
One of the most powerful features of Nunjucks is template inheritance. This allows you to define a base layout with several content blocks that can be overridden by child templates.
Let's create a new base layout at
_includes/base.njk that includes blocks for the
title, content and scripts.
_includes/base.njk
<!doctype html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{% block title %}My Eleventy Site{% endblock %}</title>
</head>
<body>
<header>
<h1>Welcome to the Site</h1>
</header>
<main>
{% block content %}{% endblock %}
</main>
<footer>
<p>© {% year %} My Company</p>
</footer>
{% block scripts %}{% endblock %}
</body>
</html>
Extending the Base Layout for a Post
Now that we have a base layout, we can create a more particular
layout for our blog posts. Create a new file that extends the base
layout and overrides the title and
content blocks.
_includes/post.njk
{% extends "base.njk" %}
{% block title %}{{ title }}{% endblock %}
{% block content %}
<article>
<h2>{{ title }}</h2>
{{ content | safe }}
</article>
{% endblock %}
The Markdown and Nunjucks Workflow
Here is where Eleventy's flexibility truly shows! By using
Nunjucks for our layouts (.njk files), we can handle
all the structural HTML, logic and templating in one
place. Simultaneously, we can use Markdown (.md
files) for writing our content.
This separation of concerns is incredibly effective. It allows content creators to focus solely on writing without needing to worry about the complexities of HTML and templating. Meanwhile, developers can build and maintain the site's structure and design in the Nunjucks layouts, knowing that the content will be slotted in correctly.
Creating Content and the Index Page
Now, let us create a new blog post in Markdown and an index page in Nunjucks to display all our posts.
posts/second-post.md
---
title: "Second Post: Nunjucks"
layout: "post.njk"
tags: "posts"
---
This is the content of my second post, now implemented with a Nunjucks layout!
Finally, let's create an index.njk at the root of our
project to list all the posts. This file will extend our
base.njk layout and use a loop to iterate over the
posts collection.
index.njk
{% extends "base.njk" %}
{% block title %}Blog Index{% endblock %}
{% block content %}
<h2>All Posts</h2>
<ul>
{% for post in collections.posts %}
<li><a href="{{ post.url }}">{{ post.data.title }}</a></li>
{% endfor %}
</ul>
{% endblock %}
Data and Filters
Let us look at a few features that help build more sophisticated sites.
The Data Cascade
Eleventy's "data cascade" is a powerful system that merges data from multiple sources and makes it available to templates. The hierarchy is as follows (from highest to lowest priority):
- Computed data.
- Front matter data in templates.
- Template data files.
- Directory data files (including their parent directories).
- Front matter data in layouts.
-
Configuration API global data (added via the
addGlobalDatamethod in the configuration file). - Global data files (from
_data/).
Note that, by default, 11ty performs a deep merge for objects and arrays, combining values from different sources.
Global Data Files
One could create global data files in the
_data/ directory. This file will hold site-wide
variables.
_data/global.json
{
"mainTitle": "Blog",
"author": "Yours Truly"
}
This data is now available in any template via the
filename "global":
{{ global.name }}.
Filters
One could extend Eleventy with custom shortcodes (as we saw
already) and filters in the
eleventy.config.js file.
Filters are functions that transform data. Here's a filter to
format the date using date-fns.
npm install date-fns
eleventy.config.js
const { format } = require("date-fns");
module.exports = function(eleventyConfig) {
eleventyConfig.addFilter("formatDate", function (date, dateFormat) {
return format(new Date(date), dateFormat || "yyyy-MM-dd");
});
return {
markdownTemplateEngine: "njk",
htmlTemplateEngine: "njk",
};
};
And to use it in templates:
{{ page.date | formatDate("MMMM d, yyyy") }}
One could also just use {{ date }} inside individual
posts. Where page refers to that context.
{# ... #}
<p>By: {{ global.author }}</p>
<p>Date: {{ date | formatDate("MMMM d, yyyy") }}</p>
{# ... #}
This assumes you have a front matter date property in
your layout with a proper value asigned to it, be it a string,
timestamp or date object. So we use the
Date constructor to generate an object for
date-fns.
Note the | pipe operator. We use it to set the first
argument of the formatDate function. Pipes can also
be used to chain filters one after the other.
We have also used the pipe operator previously to sanitize content. This is similar to how the Metalsmith plugin chain works but at a more particular level.
Templating with Next.js and the App Router
Next.js is a robust React framework for building server-rendered and statically generated applications. While React is its primary view layer, this guide focuses on integrating Nunjucks as a templating engine, leveraging the modern App Router paradigm.
This approach is useful when you need to render static HTML from complex data structures or when you want to separate template logic from your React components.
In this guide, we will build a small site that demonstrates several key data-fetching strategies within Next.js, all while using Nunjucks to render the final HTML.
Setup & Config
First, create a new Next.js application using the command-line interface, then navigate into the new directory and install the packages we'll need for Nunjucks templating, date formatting and HTML sanitization.
The installer will ask several questions; for this tutorial, we
recommend using the default answers, but ensure you select
No for TypeScript and the
app directory.
npx create-next-app@latest nunjucks-nextjs
Once the project is created, navigate into the new directory and install the packages we'll need for Nunjucks templating, date formatting, HTML sanitization and watching for file changes.
cd nunjucks-nextjs
npm install nunjucks \
date-fns \
isomorphic-dompurify \
chokidar
We will add a few folders for our templates and data.
mkdir lib templates data app/products app/articles app/dashboard
Your project structure should end up like this:
nunjucks-nextjs/
├── app/
│ ├── layout.js
│ ├── page.js
│ ├── products/
│ │ └── page.js
│ ├── articles/
│ │ └── page.js
│ └── dashboard/
│ └── page.js
├── lib/
│ └── nunjucks.js
├── templates/
│ ├── products.njk
│ ├── articles.njk
│ └── home.njk
├── data/
│ └── data.json
├── .env.local
├── package.json
└── next.config.js
Finally, create a .env.local file in the root of your
project to store environment variables, such as the
API URL and token and any URLs that
should not be hard-coded.
.env.localSTRAPI_API_URL=http://localhost:1337
PRODUCTS_API_URL=https://some.api.com/products
STRAPI_TOKEN=optional
Do we need extra Next.js configuration?
Because we use nunjucks.configure with a file path
(fs and path), Nunjucks is acting as a
back-end engine that reads files directly from the system. It is
not necessarily asking Next.js to bundle the templates; but rather
reading them, like a database would a record.
At least for these testing examples we do not need to tell Turbopack or Webpack how to manage the template files as part of our build environment.
The Nunjucks Environment
To keep our code organized, we'll create a helper module at
lib/nunjucks.js. This file is responsible for
initializing the Nunjucks environment one time (pattern known as
"a singleton") and making it available to our application.
This is also the perfect place to extend Nunjucks. In this file,
we add two custom filters:
formatDate to make timestamps human-readable and
currency to format numbers as US dollars.
lib/nunjucks.jsimport nunjucks from 'nunjucks';
import path from 'path';
import { format } from 'date-fns';
let env = null;
function getEnvironment() {
if (!env) {
env = nunjucks.configure(path.join(process.cwd(), 'templates'), {
autoescape: true,
watch: process.env.NODE_ENV === 'development',
noCache: process.env.NODE_ENV === 'development',
});
// Custom date filter
env.addFilter(
'formatDate',
function (dateString, formatString = 'MMMM d, yyyy') {
try {
return format(new Date(dateString), formatString);
} catch (error) {
console.error('Error formatting date:', error);
return dateString;
}
},
);
// Custom currency filter
env.addFilter('currency', function (value) {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD'
}).format(value);
});
}
return env;
}
export function renderNunjucks(template, context) {
try {
const environment = getEnvironment();
return environment.render(template, context);
} catch (error) {
console.error('Error rendering Nunjucks template:', error);
throw new Error(`Failed to render template ${template}: ${error.message}`);
}
}
Root Layout & Home Page
In the App Router, app/layout.js defines the root
HTML shell for every page. The
{children} prop is where the content of individual
pages will be rendered.
app/layout.jsexport const metadata = {
title: 'Next.js + Nunjucks Demo',
description: 'Integrating Nunjucks templates with Next.js App Router',
};
export default function RootLayout({ children }) {
return (
<html lang="en">
<body>
<nav>
<h1>Templating on Next.js!</h1>
</nav>
{children}
</body>
</html>
);
}
The home page at app/page.js is a Server Component,
so it can directly call our renderNunjucks function.
It passes data to the home.njk template to be
rendered.
templates/home.njk<div class="home-container">
<h2>{{ title }}</h2>
<p>{{ subtitle }}</p>
<div class="links">
<a href="/products">View Products</a>
<a href="/articles">Read Articles</a>
<a href="/dashboard">Dashboard</a>
</div>
</div>
app/page.jsimport { renderNunjucks } from '@/lib/nunjucks';
export default async function HomePage() {
const html = renderNunjucks('home.njk', {
title: 'Welcome to Next.js + Nunjucks',
subtitle:
'Combining a modern React framework and a flexible templating engine.',
});
return <div dangerouslySetInnerHTML={{ __html: html }} />;
}
Note the use of dangerouslySetInnerHTML. This React
feature is necessary to inject the HTML string rendered by
Nunjucks.
Its name serves as a reminder to always ensure the HTML you are injecting is sanitized, especially if it comes from an external source.
We will set said tring in the Articles Page section.
Static Page (Products)
For content that doesn't change often, Next.js can pre-render pages at build time. This strategy is called Static Site Generation (SSG) and offers the best performance.
Our products page will demonstrate this by fetching data from a local JSON file.
By exporting a revalidate constant, we enable
Incremental Static Regeneration (ISR). This tells Next.js
to re-build the page in the background at most once every hour, so
the content stays fresh without sacrificing performance.
data/data.json[
{
"id": 1,
"name": "Static Product A_m not sure",
"price": 15,
"description": "A reliable product for everyday use"
},
{
"id": 2,
"name": "Static Product B_est option",
"price": 50,
"description": "Premium features at an affordable price"
},
{
"id": 3,
"name": "Static Product C_annot afford it",
"price": 999.99,
"description": "Top-tier, professional solution"
}
]
templates/products.njk<h2>Products</h2>
<p>Showing {{ products.length }} products.</p>
<div>
{% for product in products %}
<div>
<h3>{{ product.name }}</h3>
<p>Price: {{ product.price | currency }}</p>
<p>Description: {{ product.description }}</p>
</div>
{% endfor %}
</div>
app/products/page.jsimport { renderNunjucks } from '@/lib/nunjucks';
import fs from 'fs/promises';
import path from 'path';
// This is a Server Component with static generation
export const revalidate = 3600; // Every hour
async function getProducts() {
try {
const filePath = path.join(process.cwd(), 'data', 'data.json');
const jsonData = await fs.readFile(filePath, 'utf-8');
return JSON.parse(jsonData);
} catch (error) {
console.error('Could not read products data:', error);
return [];
}
}
export default async function ProductsPage() {
const products = await getProducts();
const html = renderNunjucks('products.njk', { products });
return <div dangerouslySetInnerHTML={{ __html: html }} />;
}
CMS Page (Articles)
A common use case for this architecture is to fetch content from a headless CMS like Strapi. This allows content editors to manage articles without touching the codebase.
Setting up a Strapi instance is fairly straightforward, so we are covering the basics for experimenting on this project simply.
Setting up Strapi
We need to install the package while making sure to select
skip login and the default
SQLite database. This was tested on version
5.33.*.
npx create-strapi-app@latest cms-for-next
cd cms-for-next
npm run develop
Now that a server is running in watch mode and a new browser tab with our CMS has opened, we need to ensure the content is available to our Next.js application in both terms of permissions and actual content existing on the database.
-
Create a local account at
http://localhost:1337/admin. Your administrator credentials will be stored in the database. -
Go to Content-Type Builder on the left
sidebar; create new Collection Type; its
display name should read "Article". After clicking Continue, add
the Fields Text (Short text): "title", and Rich
Text (Blocks): "content". Then click on
Savefor a server restart. -
Go to
Settings; underUsers & Permissions Plugin(bottom), clickRoles, thenPublic; onArticle, check the boxes forfindandfindOne. NowSaveagain. We do this instead of using a token. -
Go to the
Content Manager; selectArticle; click+ Create new entry. Enter data on the fields and lastlyPublish.
For a simple test like this, we shall ignore using an
API token. In case one needs to fetch data that is
not entirely open to the public (like it instead happens with a
public blog), or is more comfortable with only the server having
access to the it, then consider simply including
'Authorization': 'Bearer {TOKEN}' in your
fetch headers and having that in your environment
variables.
The getArticles function fetches data from the Strapi
API. It uses isomorphic-dompurify to
sanitize the HTML content coming from the CMS. This
is a vital security step to prevent XSS attacks.
One thing to note is that the "Blocks" format from strapi gives an
array, while DOMPurify expects a string. The function
to handle this only accounts for titles and normal paragraphs (one
can add these in the Strapi web interface).
Rendering content
The Nunjucks template then uses the safe filter to
render the sanitized HTML.
templates/articles.njk<h2>Articles from CMS</h2>
{% if articles.length > 0 %}
<div>
{% for article in articles %}
<article>
<h3>{{ article.title }}</h3>
<p>Published: {{ article.publishedAt | formatDate }}</p>
<div>{{ article.content | safe }}</div>
</article>
{% endfor %}
</div>
{% else %}
<p>No articles available at this time.</p>
{% endif %}
app/articles/page.jsimport { renderNunjucks } from '@/lib/nunjucks';
import DOMPurify from 'isomorphic-dompurify';
export const revalidate = 60; // Every 60 seconds
// Convert Strapi JSON Blocks to HTML strings
function renderBlocks(blocks) {
if (!Array.isArray(blocks)) return '';
return blocks.map(block => {
// Handle Paragraphs
if (block.type === 'paragraph') {
const text = block.children.map(child => child.text).join('');
return `<p>${text}</p>`;
}
// Handle Headings
if (block.type === 'heading') {
const text = block.children.map(child => child.text).join('');
return `<h${block.level}>${text}</h${block.level}>`;
}
// Add more handlers for images, lists, etc.
return '';
}).join('');
}
async function getArticles() {
const strapiUrl = process.env.STRAPI_API_URL;
try {
const response = await fetch(`${strapiUrl}/api/articles`);
if (!response.ok) throw new Error('Failed to fetch articles from CMS');
const { data: articles = [] } = await response.json();
// Sanitize content before rendering
return articles.map(article => ({
...article,
content: DOMPurify.sanitize(renderBlocks(article.content) || ''),
title: DOMPurify.sanitize(article.title || ''),
}));
} catch (error) {
console.error('Error fetching articles:', error);
return [];
}
}
export default async function ArticlesPage() {
const articles = await getArticles(); // Finally sanitized
const html = renderNunjucks('articles.njk', { articles });
return <div dangerouslySetInnerHTML={{ __html: html }} />;
}
Client Page (Dashboard)
For highly dynamic or user-specific pages, client-side rendering
is the best approach. By placing the 'use client'
directive at the top of the file it executes in the browser.
This dashboard page does not use Nunjucks. Instead, it behaves
like a standard React component, using the
useState and useEffect hooks to fetch
and display user data.
This demonstrates how one could seamlessly mix server-rendered Nunjucks pages and client-rendered React pages in the same application.
app/dashboard/page.js'use client';
import { useState, useEffect } from 'react';
export default function Dashboard() {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
async function fetchUser() {
try {
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 1000));
setUser({
name: 'Yours Truly',
email: 'my@real.email',
joined: new Date().toLocaleDateString(),
});
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}
fetchUser();
}, []);
if (loading) return <p>Loading dashboard...</p>;
if (error) return <p>Error: {error}</p>;
return (
<div>
<h2>Dashboard</h2>
<div>
<h3>Welcome, {user.name}!</h3>
<p>
<strong>Email</strong>: {user.email}
</p>
<p>
<strong>Member since</strong>: {user.joined}
</p>
</div>
</div>
);
}
Running the Project
To start the development server from the project's root directory:
npm install
npm run dev
The different pages to see the results of each rendering strategy (all accessible via links):
-
http://localhost:3000: Server-rendered home page. -
http://localhost:3000/products: Statically generated page with products from a JSON file. -
http://localhost:3000/articles: Server-rendered page fetching content from Strapi CMS. -
http://localhost:3000/dashboard: Client-rendered page fetching user-specific data.
Explore more of my work!
It is my hope that this concise introduction to the subject matter has proven beneficial.
Be welcomed to share with developer friends.