Handlebars Template Engine Implementation

Frontend Engineering intermediate Building with AI ~25 min read

Overview

Template engines sit at the boundary between structured data and human-readable output. Building one from scratch — with a lexer that tokenizes raw template strings, a parser that constructs an abstract syntax tree, an evaluator that walks the tree against a data context, and a helper registration system for extensibility — teaches foundational compiler design concepts in a practical, immediately useful context. Custom Handlebars-style template engines are commonly used in production applications for rendering dynamic content like email bodies, notification messages, and search result snippets.

A Handlebars template engine processes strings containing {{expression}} placeholders and control structures like {{#if}}, {{#each}}, and {{#with}}. Unlike simple string interpolation (replacing %s placeholders), a full template engine supports nested contexts, block helpers, partials, and HTML escaping — features that require a multi-phase pipeline rather than a single regex pass. Understanding this pipeline demystifies not just Handlebars, but the broader class of domain-specific language interpreters used throughout modern software.

The implementation pattern also illustrates a critical design tension: how much of the host language’s power should be exposed to the template? Handlebars deliberately restricts templates to be “logic-less” — no arbitrary JavaScript execution, no complex expressions — forcing business logic into helpers that are registered separately. This separation makes templates safer to execute with untrusted data and easier to reason about, at the cost of requiring more boilerplate for complex rendering scenarios.

Core Concepts

Lexer (Tokenizer)

The lexer scans the raw template string character by character, producing a stream of tokens: TEXT for literal content, OPEN for {{, CLOSE for }}, ID for identifiers like variable names, BLOCK_OPEN for {{#, and BLOCK_CLOSE for {{/. The lexer handles edge cases like triple-stache {{{ for unescaped output, whitespace control with {{~, and escaped delimiters. Each token carries its position (line and column) for error reporting.

Abstract Syntax Tree (AST)

The parser consumes the token stream and builds a tree structure where each node represents a template construct: TextNode for literal text, MustacheNode for expression interpolation, BlockNode for control structures with a body and optional inverse (else) block, and PartialNode for template inclusion. The AST captures the hierarchical nesting of blocks — an {{#each items}} block contains child nodes that are evaluated once per array element.

Evaluation Context Stack

When the evaluator walks the AST, it maintains a stack of data contexts. The root context is the data object passed to the template. Block helpers like {{#each}} and {{#with}} push new contexts onto the stack — inside an {{#each items}} block, the current context is the individual array element. The ../ path syntax navigates up the context stack, allowing access to parent data from within nested blocks. This stack-based scoping mirrors how closures work in programming languages.

Helper Functions

Helpers extend the template engine’s capabilities without embedding arbitrary code in templates. A helper is a named function registered with the engine that receives the current context, any arguments from the template expression, and an options hash containing the block body (for block helpers). Built-in helpers like if, each, with, and unless implement control flow, while custom helpers handle domain-specific logic like date formatting, pluralization, or URL construction.

HTML Escaping and SafeString

By default, all expression output is HTML-escaped to prevent XSS attacks — < becomes <, " becomes ", etc. When a helper or value is known to be safe (e.g., pre-rendered HTML), it can be wrapped in a SafeString object that signals the evaluator to skip escaping. Triple-stache {{{expression}}} also bypasses escaping. This default-safe approach is the template engine’s primary security mechanism.

How It Works

Phase 1: Lexical Analysis

The lexer maintains a cursor position and scans forward through the template string. When it encounters {{, it emits any accumulated text as a TEXT token, then switches to “expression mode” where it tokenizes identifiers, dots (for path access), literals, and the closing }}. The lexer distinguishes between {{ (escaped expression), {{{ (unescaped expression), {{# (block open), {{/ (block close), {{> (partial), and {{! (comment).

// Lexer implementation in TypeScript
interface Token {
  type: 'TEXT' | 'OPEN' | 'CLOSE' | 'ID' | 'DOT' | 'BLOCK_OPEN'
        | 'BLOCK_CLOSE' | 'INVERSE' | 'PARTIAL' | 'COMMENT'
        | 'TRIPLE_OPEN' | 'TRIPLE_CLOSE' | 'STRING' | 'NUMBER';
  value: string;
  line: number;
  col: number;
}

function lex(template: string): Token[] {
  const tokens: Token[] = [];
  let i = 0, line = 1, col = 1;
  let textStart = 0;

  while (i < template.length) {
    // Check for opening delimiters
    if (template[i] === '{' && template[i + 1] === '{') {
      // Emit accumulated text
      if (i > textStart) {
        tokens.push({
          type: 'TEXT', value: template.slice(textStart, i),
          line, col: col - (i - textStart)
        });
      }

      // Determine delimiter type
      if (template[i + 2] === '{') {
        tokens.push({ type: 'TRIPLE_OPEN', value: '{{{', line, col });
        i += 3; col += 3;
      } else if (template[i + 2] === '#') {
        tokens.push({ type: 'BLOCK_OPEN', value: '{{#', line, col });
        i += 3; col += 3;
      } else if (template[i + 2] === '/') {
        tokens.push({ type: 'BLOCK_CLOSE', value: '{{/', line, col });
        i += 3; col += 3;
      } else if (template[i + 2] === '!') {
        // Skip comment content until }}
        const end = template.indexOf('}}', i + 3);
        i = end + 2; col += (end + 2 - i);
        textStart = i;
        continue;
      } else {
        tokens.push({ type: 'OPEN', value: '{{', line, col });
        i += 2; col += 2;
      }

      // Tokenize expression content until closing delimiter
      while (i < template.length) {
        if (template[i] === '}' && template[i + 1] === '}') {
          if (template[i + 2] === '}') {
            tokens.push({ type: 'TRIPLE_CLOSE', value: '}}}', line, col });
            i += 3; col += 3;
          } else {
            tokens.push({ type: 'CLOSE', value: '}}', line, col });
            i += 2; col += 2;
          }
          textStart = i;
          break;
        }
        // Tokenize identifiers, dots, strings, numbers within expression
        if (/[a-zA-Z_]/.test(template[i])) {
          let id = '';
          while (i < template.length && /[a-zA-Z0-9_]/.test(template[i])) {
            id += template[i]; i++; col++;
          }
          tokens.push({ type: 'ID', value: id, line, col: col - id.length });
        } else if (template[i] === '.') {
          tokens.push({ type: 'DOT', value: '.', line, col });
          i++; col++;
        } else {
          i++; col++; // skip whitespace in expressions
        }
      }
    } else {
      if (template[i] === '\n') { line++; col = 1; } else { col++; }
      i++;
    }
  }

  // Emit trailing text
  if (textStart < template.length) {
    tokens.push({ type: 'TEXT', value: template.slice(textStart), line, col });
  }

  return tokens;
}

Phase 2: Parsing into an AST

The parser walks the token stream and builds a tree. When it encounters a BLOCK_OPEN token (like {{#each}}), it recursively parses child nodes until it finds the matching BLOCK_CLOSE token ({{/each}}). This recursive descent approach naturally handles nested blocks.

// AST node types
type ASTNode =
  | { type: 'text'; value: string }
  | { type: 'mustache'; path: string[]; escaped: boolean }
  | { type: 'block'; helper: string; args: string[]; body: ASTNode[];
      inverse: ASTNode[] | null }
  | { type: 'partial'; name: string };

function parse(tokens: Token[]): ASTNode[] {
  const nodes: ASTNode[] = [];
  let i = 0;

  function parseUntil(closeHelper?: string): ASTNode[] {
    const result: ASTNode[] = [];

    while (i < tokens.length) {
      const token = tokens[i];

      if (token.type === 'TEXT') {
        result.push({ type: 'text', value: token.value });
        i++;

      } else if (token.type === 'OPEN') {
        i++; // skip {{
        const path = parsePath();
        i++; // skip }}
        result.push({ type: 'mustache', path, escaped: true });

      } else if (token.type === 'TRIPLE_OPEN') {
        i++; // skip {{{
        const path = parsePath();
        i++; // skip }}}
        result.push({ type: 'mustache', path, escaped: false });

      } else if (token.type === 'BLOCK_OPEN') {
        i++; // skip {{#
        const helper = tokens[i].value; i++;
        const args = parseArgs();
        i++; // skip }}
        const body = parseUntil(helper);
        // Check for {{else}} / inverse block
        let inverse: ASTNode[] | null = null;
        if (i < tokens.length && tokens[i].type === 'INVERSE') {
          i++;
          inverse = parseUntil(helper);
        }
        i++; // skip {{/helper}}
        result.push({ type: 'block', helper, args, body, inverse });

      } else if (token.type === 'BLOCK_CLOSE') {
        // Verify matching close tag
        i++; // skip {{/
        const name = tokens[i].value; i++;
        i++; // skip }}
        if (closeHelper && name !== closeHelper) {
          throw new Error(
            `Mismatched block: expected {{/${closeHelper}}}, got {{/${name}}}`
          );
        }
        return result;
      } else {
        i++;
      }
    }
    return result;
  }

  return parseUntil();
}

Phase 3: Evaluation

The evaluator walks the AST with a context stack, resolving paths against the current context and delegating block evaluation to helper functions. Each AST node type has a corresponding evaluation rule.

type HelperFn = (
  context: any, args: any[], options: { fn: (ctx: any) => string; inverse: (ctx: any) => string }
) => string;

class HandlebarsEngine {
  private helpers: Map<string, HelperFn> = new Map();
  private partials: Map<string, ASTNode[]> = new Map();

  constructor() {
    // Register built-in helpers
    this.helpers.set('if', (ctx, args, opts) =>
      args[0] ? opts.fn(ctx) : opts.inverse(ctx)
    );
    this.helpers.set('each', (ctx, args, opts) => {
      const arr = args[0];
      if (!Array.isArray(arr)) return '';
      return arr.map((item, index) =>
        opts.fn({ ...item, '@index': index, '@first': index === 0,
                  '@last': index === arr.length - 1 })
      ).join('');
    });
    this.helpers.set('with', (ctx, args, opts) =>
      args[0] ? opts.fn(args[0]) : opts.inverse(ctx)
    );
  }

  registerHelper(name: string, fn: HelperFn) {
    this.helpers.set(name, fn);
  }

  evaluate(nodes: ASTNode[], context: any, contextStack: any[] = []): string {
    return nodes.map(node => {
      switch (node.type) {
        case 'text':
          return node.value;

        case 'mustache': {
          const value = this.resolvePath(node.path, context, contextStack);
          const str = String(value ?? '');
          return node.escaped ? escapeHtml(str) : str;
        }

        case 'block': {
          const helper = this.helpers.get(node.helper);
          if (!helper) throw new Error(`Unknown helper: ${node.helper}`);
          const args = node.args.map(a =>
            this.resolvePath(a.split('.'), context, contextStack)
          );
          return helper(context, args, {
            fn: (ctx) => this.evaluate(
              node.body, ctx, [context, ...contextStack]
            ),
            inverse: (ctx) => this.evaluate(
              node.inverse ?? [], ctx, [context, ...contextStack]
            ),
          });
        }

        default:
          return '';
      }
    }).join('');
  }

  private resolvePath(path: string[], context: any, stack: any[]): any {
    let current = context;
    for (const segment of path) {
      if (segment === '..') {
        current = stack.length > 0 ? stack[0] : context;
        continue;
      }
      if (current == null) return undefined;
      current = current[segment];
    }
    return current;
  }
}

function escapeHtml(str: string): string {
  return str
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#x27;');
}

Practical Applications

As an example, a custom Handlebars-style template engine can render dynamic email content for outreach campaigns. Each email template uses {{#each recipients}} to iterate over contact lists, {{#if company.isPremium}} to conditionally include premium-specific messaging, and custom helpers like {{formatDate scheduledAt "MMM DD"}} to produce human-readable dates. By keeping the template engine in-house rather than depending on the full Handlebars.js library, a team controls the exact feature set, avoids the 300KB bundle size, and can add domain-specific helpers without forking an external dependency.

A similar template engine implemented in Zig works well for server-side rendering of search result snippets. When a search query matches documents, the result snippet template uses {{{highlight}}} (triple-stache, unescaped) to insert pre-computed HTML highlight spans, while other fields like {{title}} use escaped output to prevent XSS from user-generated content. A Zig implementation demonstrates how the same lexer-parser-evaluator pattern works across languages — the AST structure is nearly identical, but memory management (arena allocators in Zig vs. garbage collection in TypeScript) changes the implementation details significantly.

Template compilation — parsing the template once and reusing the AST for multiple evaluations — provides a significant performance improvement for templates rendered in tight loops. Compiling each template at startup and caching the AST reduces per-render cost to just the evaluation phase. For a batch of 10,000 emails, this eliminates 9,999 redundant lex+parse passes.

Implementation Patterns

Compile-Once, Evaluate-Many

Separate template compilation (lex + parse) from evaluation so the AST can be reused across multiple data contexts. This is the standard pattern in production template engines — Handlebars.js exposes Handlebars.compile(template) which returns a function that accepts data and returns rendered output. The compilation step is O(n) in template length; evaluation is O(n) in AST size plus the cost of resolving paths against the data context.

Helper Registration API

Expose a registerHelper(name, fn) method that lets consumers extend the engine without modifying its internals. Helpers should receive a well-defined options object containing the block body function, the inverse function, the current context, and any hash arguments. This API contract allows helpers to be written and tested independently of the engine.

Strict Mode for Missing Variables

In development, enable a strict mode that throws an error when a template references a path that doesn’t exist in the data context, rather than silently rendering an empty string. This catches typos and data shape mismatches early. In production, fall back to silent empty strings to prevent rendering failures from crashing the application.

Common Pitfalls

Further Reading