Precompiled Handlebars with Escaped Output: An Architecture Note
An architecture note on the smallest safe Handlebars setup: precompiled templates, escaped-by-default output, pure helpers, and the checks that confirm the trust boundaries hold.
28 Jun 2026, 12:04 UTC

If your app renders HTML from untrusted data — user profiles, API payloads, form submissions — the two decisions that matter most with Handlebars are boring ones: precompile your templates at build time, and let every value flow through the default escaped {{value}} syntax. This note lays out why that is the smallest design that satisfies the requirements, where the trust boundaries sit, and how to check the design is actually holding in production.
Requirements
The design assumes a fairly common situation:
- Templates are authored by your own team and shipped with the application.
- Interpolated data is untrusted: it may contain markup, script tags, or quotes.
- The client bundle should stay small and should not pay template-parse cost on every page load.
- Rendering must be testable in isolation, without a browser or network.
If your requirements differ — for example, untrusted users author templates — this design does not apply. Handlebars is not a sandbox: compiled templates and helpers run with full application privileges, so compiling user-supplied templates is equivalent to running user-supplied JavaScript. That case needs a restricted template language instead.
The smallest suitable design
Three rules cover it:
- Precompile everything. Templates are compiled to JavaScript functions at build time (via the
handlebarsprecompile CLI or a bundler plugin). The browser receives only the small Handlebars runtime plus the compiled functions — not the full compiler. This shrinks the payload and removes runtime parsing entirely. - Escape by default, raw by exception. Double mustache
{{value}}HTML-escapes output; triple mustache{{{value}}}renders raw HTML. The policy is: everything uses double mustache, and any triple mustache requires a review comment naming the sanitizer that produced the string. - Helpers are pure functions. Custom helpers take input and return a string. No network calls, no state mutation, no business decisions. That keeps templates testable and keeps the data boundary legible.
That is the whole architecture. No layout engine, no helper registry with side effects, no runtime compilation path.
Trust and data boundaries
There are three boundaries worth naming explicitly:
- Templates are trusted code. They ship with your app and are reviewed like any other source file. Precompilation enforces this: there is no path for a template string to arrive at runtime and get compiled.
- Interpolated data is untrusted. It enters only through escaped expressions, or through a helper you wrote and tested. The moment data crosses into raw output, something upstream must have sanitized it.
- On the server, the file system is a boundary too. If partials or layouts are loaded from disk in Node.js, restrict resolution to a fixed template directory. A partial path influenced by request input is a path-traversal problem wearing a templating costume.
One caveat on escaping: default mustache escaping protects the HTML element and attribute context only. Data placed inside a <script> block, a URL query parameter, or inline CSS needs context-appropriate encoding — Handlebars' default escaping is not sufficient there.
A concrete check of the escaping boundary
The following Node.js script (run anywhere with Handlebars installed, no special permissions) demonstrates the boundary and doubles as a smoke test:
const Handlebars = require('handlebars');
const data = { bio: '<script>alert(1)</script>' };
const escaped = Handlebars.compile('<p>{{bio}}</p>')(data);
const raw = Handlebars.compile('<p>{{{bio}}}</p>')(data);
console.log(escaped); // expect <script>... entities
console.log(raw); // expect the literal script tag
if (escaped.includes('<script>')) {
throw new Error('Escaping boundary broken');
}
The escaped render should contain HTML entities; the raw render should contain the literal tag. If the first assertion ever fails, your escaping assumptions — and everything built on them — are wrong. Note that exact precompile CLI flags and runtime file names vary across Handlebars versions, so confirm them against your installed version rather than copying flags from a tutorial.
Operational checks
These are cheap checks that catch the realistic failure modes:
- Bundle inspection. Confirm the production bundle contains the Handlebars runtime and compiled template functions, not the full compiler. A missing precompile step usually shows up as a mysteriously large vendor chunk.
- Triple-mustache audit. Grep the template source for
{{{. Every hit should have a review comment and a named sanitizer. This is a ten-second check that catches the highest-severity bug class (XSS on user data). - Helper purity tests. Unit-test each custom helper with fixed inputs and assert the returned string. If a helper is hard to test this way, it is doing too much.
- Helper name collisions. Registering a helper named
iforeachsilently overrides the built-in. Assert your registered helper names against the reserved list in CI. - Partial path confinement (server). Attempt to resolve a partial outside the configured template directory and confirm it fails.
Failure modes
In rough order of likelihood:
- Accidental raw output. A developer reaches for
{{{value}}}to fix a rendering quirk and ships XSS. The grep audit is the countermeasure. - Compiler shipped to production. The precompile step is skipped in one build variant. The bundle check catches it.
- Logic creep in helpers. A helper grows a conditional, then a fetch, then a cache. Templates become untestable and the data boundary dissolves. The purity test is the early warning.
- Wrong-context escaping. Data rendered into a URL or script context with only HTML escaping. This needs a code-review rule, not a tool.
When this design stops fitting
Revisit the design when: views accumulate enough conditional logic that templates read like programs (migrate to a component framework); pages get large enough that you need streaming rendering (Handlebars renders to a complete string); or template authoring opens to anyone outside the team (Handlebars gives templates full privileges, so you need a sandboxed language). Until one of those is true, precompiled templates plus escaped-by-default output is the smallest thing that works — and it is easy to verify that it keeps working.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.