Handlebars Custom Helpers: Where Your Template Logic Should Actually Live
Custom Handlebars helpers keep templates declarative, but escaping rules and SafeString trip up most first attempts. Here's how to register helpers, build block helpers, and know when you've gone too far.
10 Dec 2025, 18:57 UTC

Your Handlebars template starts clean: a few {{name}} interpolations, an {{#each}} loop. Then someone needs to format a date, truncate a string, and show a badge only for admin users. The temptation is to pre-massage all of that in the route handler — but then every rendering path needs the same shaping code, and your data layer grows view concerns. The better answer is custom helpers: small, testable JavaScript functions that keep templates declarative without dragging logic back into controllers.
The catch is that helpers interact with Handlebars' escaping rules in ways that bite almost everyone the first time. This post covers how to register helpers, when to use Handlebars.SafeString, and how block helpers unlock custom control flow — plus the line where helpers become a smell.
Escaping is the default, and helpers don't get a pass
Double-mustache interpolation ({{expression}}) HTML-escapes its output. That applies to helper return values too: if your helper returns a plain string containing markup, Handlebars escapes it. This is the single most common helper bug — you write a badge helper that returns <span class="badge">Admin</span>, and the page shows the literal tags instead of a styled badge.
The fix is explicit opt-out. Wrap intentionally-generated markup in Handlebars.SafeString:
const Handlebars = require('handlebars');
Handlebars.registerHelper('badge', function (label) {
const escaped = Handlebars.escapeExpression(label);
return new Handlebars.SafeString(
`<span class="badge">${escaped}</span>`
);
});Note the two-step: escape the data going into the markup, then mark the whole result as safe. If the label comes from a user and you skip escapeExpression, you've built an XSS hole with extra steps. The same caution applies to triple-mustache ({{{value}}}), which bypasses escaping entirely — never point it at user-controlled input without separate sanitization.
Registering and calling helpers
Registration is one call, typically done once at application startup (server-side in Node, or before compiling templates in the browser):
Handlebars.registerHelper('formatDate', function (isoString) {
if (!isoString) return '';
return new Date(isoString).toLocaleDateString('en-US', {
year: 'numeric', month: 'short', day: 'numeric'
});
});In the template, call it inline with arguments that can be context values, literals, or subexpressions:
<p>Published {{formatDate post.publishedAt}}</p>
<p>{{truncate post.summary 120}}</p>
<p>{{formatDate (fallback post.updatedAt post.createdAt)}}</p>Two things worth knowing. First, registration is global to the Handlebars instance — if two modules register a helper named format, the last one wins silently. Prefix app-specific helpers (appFormatDate) if you're in a large codebase or consuming third-party helper packs. Second, helpers are plain functions, which means you can unit-test them without touching a template: call the function, assert on the string. That testability is the real payoff over stuffing logic into templates.
Block helpers: custom control flow
When a helper needs to wrap content — custom conditionals, iteration with extra behavior, section rendering — use the block form. Block helpers receive an options object where options.fn(context) renders the block body and options.inverse(context) renders the {{else}} section:
Handlebars.registerHelper('ifRole', function (role, options) {
const user = options.data.root.currentUser;
if (user && user.role === role) {
return options.fn(this);
}
return options.inverse(this);
});{{#ifRole "admin"}}
<a href="/settings">Manage site</a>
{{else}}
<p>Read-only access.</p>
{{/ifRole}}Here options.data.root reaches the top-level context, which is how you access values outside the current block scope. To verify the behavior, render the template with a matching and a non-matching role and confirm which branch appears — a two-line test catches inverted fn/inverse mistakes immediately.
The trade-off: helpers can become a junk drawer
Helpers keep templates logic-light, but "logic-light" isn't "logic-free." If you find yourself writing helpers that query services, mutate state, or encode business rules ("discount applies when..."), you've rebuilt your controller inside the view layer — with worse tooling and no type checking. A useful rule of thumb: a helper should transform data already present in the context into display form. Formatting, truncation, pluralization, conditional rendering — yes. Fetching, deciding policy, computing derived domain state — no; that belongs upstream.
There's also a performance angle. If you're rendering client-side, precompile templates with the Handlebars CLI (handlebars template.hbs -f template.js, run from your build script with the handlebars package installed) so browsers skip compilation. The precompiled output must pair with a compatible runtime version — check your lockfile and use the matching handlebars.runtime build, since mismatches across 4.x releases can produce confusing failures.
A practical starting point
Audit one template that's grown messy. Extract anything that smells like formatting into a registered helper, write a unit test for it, and render a page containing <script> in the data to confirm escaping still works end to end. If a helper needs to emit markup, make the SafeString wrap and the inner escapeExpression call a code-review checklist item. You'll end up with templates a designer can read and logic a test suite can cover — which is the whole point.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.