Skip to main content

Guide

Handlebars vs EJS vs eta (2026)

Compare Handlebars, EJS, and eta for server-side HTML templating in Node.js. Email templates, Express integration, partials, helpers, performance, TypeScript.

·PkgPulse Team·
0

TL;DR

Handlebars enforces logic-less templates — HTML with {{variable}} syntax, partials, helpers, and no JavaScript in the template itself, which keeps templates clean and maintainable. EJS is the most familiar — it embeds JavaScript directly (<% code %>, <%= output %>), making it the closest to PHP/JSP-style templating and easiest to learn. eta is the modern choice — TypeScript-native, fastest, smallest (~3KB), and works in browsers too. For email templates with complex layouts: Handlebars. For quick server-rendered HTML: EJS. For performance-critical or TypeScript-first projects: eta.

Key Takeaways

  • handlebars: ~25M weekly downloads — logic-less, partials, helpers, email template standard
  • ejs: ~20M weekly downloads — embedded JS, easiest to learn, closest to HTML
  • eta: ~3M weekly downloads — TypeScript-native, fastest, 3KB, browser + Node.js
  • In 2026, server-side templates are primarily used for: emails, RSS/XML/sitemap generation, PDF generation, server-rendered admin UIs
  • For new web apps, React/Vue/Svelte and SSR frameworks dominate — template engines are niche
  • Handlebars is the standard for email templates (react-email uses JSX, but nodemailer still often uses Handlebars)

PackageWeekly DownloadsBundle SizeLogic-lessPartialsTypeScript
handlebars~25M~180KB✅ @types
ejs~20M~40KB✅ include✅ @types
eta~3M~3KB✅ Native

Handlebars

Handlebars — the logic-less template standard:

Basic syntax

{{!-- email-template.hbs --}}
<!DOCTYPE html>
<html>
<head>
  <title>{{subject}}</title>
</head>
<body>
  <h1>Hello, {{user.firstName}}!</h1>

  {{!-- Conditional block --}}
  {{#if isPremium}}
    <p>Thank you for your Premium subscription!</p>
  {{else}}
    <p>Upgrade to Premium for full access.</p>
  {{/if}}

  {{!-- Loop --}}
  <h2>Your watched packages:</h2>
  <ul>
    {{#each packages}}
      <li>
        <strong>{{name}}</strong> — Score: {{score}}/100
        {{#if this.deprecated}}(deprecated){{/if}}
      </li>
    {{else}}
      <li>No packages watched yet.</li>
    {{/each}}
  </ul>

  {{!-- Partial --}}
  {{> footer company="PkgPulse" year=2026}}
</body>
</html>

Node.js usage

import Handlebars from "handlebars"
import { readFile } from "fs/promises"

// Compile from string:
const template = Handlebars.compile(`
  <h1>Hello, {{name}}!</h1>
  <p>Your package {{package}} has a health score of {{score}}/100.</p>
`)

const html = template({
  name: "Alice",
  package: "react",
  score: 95,
})

// Compile from file:
async function renderTemplate(templatePath: string, data: Record<string, unknown>) {
  const source = await readFile(templatePath, "utf8")
  const template = Handlebars.compile(source)
  return template(data)
}

const emailHtml = await renderTemplate("templates/welcome-email.hbs", {
  user: { firstName: "Alice", email: "alice@example.com" },
  isPremium: true,
  packages: [
    { name: "react", score: 95, deprecated: false },
    { name: "moment", score: 45, deprecated: true },
  ],
})

Custom helpers

import Handlebars from "handlebars"

// Register helpers:
Handlebars.registerHelper("formatScore", (score: number) => {
  if (score >= 80) return new Handlebars.SafeString(`<span class="green">${score}</span>`)
  if (score >= 60) return new Handlebars.SafeString(`<span class="yellow">${score}</span>`)
  return new Handlebars.SafeString(`<span class="red">${score}</span>`)
})

Handlebars.registerHelper("formatDate", (date: Date | string) => {
  return new Date(date).toLocaleDateString("en-US", {
    year: "numeric", month: "long", day: "numeric"
  })
})

Handlebars.registerHelper("gt", (a: number, b: number) => a > b)

// Use in template:
// {{formatScore score}}
// {{formatDate publishedAt}}
// {{#if (gt score 80)}}Healthy{{/if}}

Partials

import Handlebars from "handlebars"
import { readFileSync } from "fs"

// Register partials (reusable template fragments):
Handlebars.registerPartial(
  "header",
  readFileSync("templates/partials/header.hbs", "utf8")
)

Handlebars.registerPartial(
  "footer",
  readFileSync("templates/partials/footer.hbs", "utf8")
)

// header.hbs:
// <header>
//   <h1>{{title}}</h1>
//   <nav>...</nav>
// </header>

// Use in template: {{> header title="PkgPulse Dashboard"}}

EJS

EJS — embedded JavaScript templates:

Basic syntax

<!-- user-report.ejs -->
<!DOCTYPE html>
<html>
<head>
  <title>PkgPulse Report</title>
</head>
<body>
  <h1>Hello, <%= user.firstName %>!</h1>
  <%# This is a comment — not rendered %>

  <!-- Conditional: -->
  <% if (user.isPremium) { %>
    <p class="premium">Premium member</p>
  <% } else { %>
    <p><a href="/upgrade">Upgrade to Premium</a></p>
  <% } %>

  <!-- Loop: -->
  <ul>
    <% for (const pkg of packages) { %>
      <li>
        <strong><%= pkg.name %></strong> —
        <%= pkg.score %>/100
        <% if (pkg.deprecated) { %>
          <span class="badge">Deprecated</span>
        <% } %>
      </li>
    <% } %>
  </ul>

  <!-- Include (partial): -->
  <%- include('partials/footer', { year: new Date().getFullYear() }) %>
</body>
</html>

EJS tags

<% code %>     — Execute JavaScript (no output)
<%= value %>   — Output escaped HTML value
<%- value %>   — Output unescaped HTML (use for trusted HTML)
<%# comment %> — Comment (not rendered to output)
<%_ ... _%>    — Strip whitespace before/after
<% ... -%>     — Remove newline after tag

Node.js usage

import ejs from "ejs"
import { readFile } from "fs/promises"

// Render from string:
const html = ejs.render(`
  <h1>Hello, <%= name %>!</h1>
  <p>You have <%= count %> packages tracked.</p>
`, {
  name: "Alice",
  count: 12,
})

// Render from file (async):
const html2 = await ejs.renderFile(
  "templates/user-report.ejs",
  {
    user: { firstName: "Alice", isPremium: true },
    packages: [{ name: "react", score: 95, deprecated: false }],
  },
  { async: true }
)

// Express integration:
import express from "express"
const app = express()

app.set("view engine", "ejs")
app.set("views", "./views")

app.get("/report", (req, res) => {
  res.render("user-report", {
    user: req.user,
    packages: userPackages,
  })
})

eta

eta — the fast, modern TypeScript-native template engine:

Basic syntax

// eta uses similar tags to EJS but faster:
// <%= %> — escaped output
// <%~ %> — unescaped output
// <% %>  — execute code
// <%# %> — comment

const template = `
  <h1>Hello, <%= it.name %>!</h1>
  <!-- it is the data object in eta -->

  <% if (it.isPremium) { %>
    <span class="badge">Premium</span>
  <% } %>

  <ul>
    <% it.packages.forEach(function(pkg) { %>
      <li><%= pkg.name %> — <%= pkg.score %>/100</li>
    <% }) %>
  </ul>
`

Usage

import { Eta } from "eta"
import { resolve } from "path"

// Create eta instance with config:
const eta = new Eta({
  views: resolve("./templates"),  // Template directory
  cache: true,                    // Cache compiled templates (default: false in dev)
})

// Render from string:
const html = eta.renderString(
  "<h1>Hello, <%= it.name %>!</h1>",
  { name: "Alice" }
)

// Render from file (auto-appends .eta extension):
const emailHtml = eta.render("welcome-email", {
  user: { firstName: "Alice" },
  packages: [{ name: "react", score: 95 }],
})

// Async rendering:
const html2 = await eta.renderAsync("heavy-template", data)

Why eta is fastest

// eta precompiles templates to optimized JS functions at startup:
// - No runtime parsing for repeat renders
// - Tight integration with V8's JIT compiler
// - Minimal overhead vs raw string concatenation

// Benchmark (1000 renders, medium template):
// eta:        ~0.8ms
// ejs:        ~2.5ms
// handlebars: ~3.2ms

// For high-traffic servers rendering thousands of pages/second,
// this matters. For most apps, any engine works fine.

// eta also works in browsers (3KB):
import { Eta } from "eta/browser"
const eta = new Eta()
const html = eta.renderString("Hello <%= it.name %>!", { name: "World" })

Feature Comparison

FeatureHandlebarsEJSeta
Bundle size~180KB~40KB~3KB
Logic-less
Embedded JS
Partials/includes✅ include
Custom helpers❌ (use helpers)
TypeScript✅ @types✅ @types✅ Native
Browser support
PerformanceModerateGoodFastest
Express integration
Async templates
Caching

When to Use Each

Choose Handlebars if:

  • Email templates — Handlebars is the industry standard for email (nodemailer, SendGrid)
  • You want separation of concerns — designers can edit templates without touching JavaScript
  • Logic-less templates prevent abuse (no arbitrary code execution in templates)
  • You need the helper system for reusable formatting functions

Choose EJS if:

  • Quickest to learn for developers familiar with PHP/ASP/JSP
  • Generating XML, RSS, or sitemaps where you want template syntax without setup
  • Express apps where you want view templates with full JavaScript access
  • Simple admin panels or server-rendered UIs for internal tools

Choose eta if:

  • Performance is critical (high-traffic server rendering)
  • TypeScript-first development — eta's types are native, not added
  • Browser-side templating (3KB bundle)
  • You want modern EJS-style syntax in a smaller package

Consider alternatives for web apps:

  • For user-facing web applications in 2026: React, Vue, Svelte with SSR (Next.js, Nuxt, SvelteKit)
  • Template engines are best for: emails, XML generation, server-rendered admin tools, PDF generation

Security: Template Injection and XSS Prevention

Server-side template engines are a common source of cross-site scripting (XSS) vulnerabilities when developers mix trusted and untrusted content incorrectly. Handlebars uses {{variable}} for HTML-escaped output (safe for user-supplied content) and {{{variable}}} for raw unescaped output (only for trusted HTML). This distinction is enforced at the syntax level, making it visually obvious which outputs might be dangerous. EJS uses <%= value %> for escaped output and <%- value %> for unescaped output — similar convention, but the visual difference between = and - is less obvious than triple braces, and developers sometimes default to <%- for convenience without thinking through the safety implications. eta mirrors EJS's pattern with <%= %> for escaped and <%~ %> for unescaped output. Template injection — where an attacker can inject template syntax into user-supplied data that is then rendered — is a risk primarily when templates are constructed dynamically from user input. All three libraries treat template strings as trusted input: never render a template where the template source itself comes from user input without extremely careful sanitization.

Performance at Scale and Caching Strategies

Template engine performance matters most for server-rendered applications that generate unique HTML for each request. eta's performance advantage — roughly 3x faster than EJS and Handlebars on repeat renders — comes from its precompilation approach: templates are compiled to JavaScript functions once (at startup or on first render), and subsequent renders invoke the compiled function directly without re-parsing the template syntax. Handlebars also precompiles templates through Handlebars.precompile(), which can be run at build time and the compiled template stored as a string for distribution. This means in production, Handlebars render performance is comparable to eta when templates are precompiled — the benchmark differences primarily reflect just-in-time compilation overhead. EJS's default behavior re-parses templates on each render call unless the cache: true option is set with express-ejs-layouts or a custom caching layer. For high-traffic servers rendering thousands of unique templates per second (report generation, personalized email preview), precompiling templates and caching the compiled functions is more impactful than the choice of library.

TypeScript Type Safety for Template Data

Type-checking the data passed to templates is an underappreciated developer experience improvement. Without type checking, passing the wrong data shape to a template causes runtime errors that only appear when the specific template path is executed in production. Handlebars and EJS have no built-in mechanism for asserting the shape of template data — you pass a plain object and the template renders whatever properties it can find. eta's native TypeScript support enables a pattern where the template's expected data type is defined as an interface and enforced at the call site: eta.render("report", data as ReportData) gives TypeScript visibility but not compile-time guarantee of the data shape. The most reliable approach for type-safe templates in 2026 is to use Zod or similar validation at the render call site — validate the data object against a schema before passing it to the template engine, ensuring runtime safety even if compile-time types drift from the actual data. This pattern works with all three libraries and catches issues that type annotations alone would miss.

Migration from React/JSX to Template Engines

Teams building server-rendered admin UIs or internal tools sometimes move in the opposite direction — away from React/Next.js toward simpler template engines for parts of their application that do not need client-side interactivity. The motivations are typically operational: removing the Node.js runtime requirement for React hydration, reducing bundle size for tools used internally, or simplifying deployment. EJS is the easiest starting point for teams familiar with React JSX because the <% %> interpolation is similar to JSX expressions, and the mental model of "HTML with data inserted" is straightforward. Handlebars requires a mindset shift because its logic-less philosophy means moving conditionals and loops to helpers rather than embedding them inline. eta feels like a middle ground — it allows embedded JavaScript like EJS but encourages a more disciplined approach through its it. data prefix convention. For email templates specifically, migrating from React Email (JSX-based) to Handlebars is common when a team wants to separate template editing from application code, allowing designers to modify .hbs files in a repository without Node.js toolchain knowledge.

Ecosystem Position and When to Choose Each in 2026

In 2026, the practical use cases for server-side template engines have narrowed but not disappeared. Email templates remain the primary use case for Handlebars — even as React Email gains adoption, Handlebars templates remain the standard for teams that want to separate email design from application code, allow non-developer content editors to modify email copy, and avoid a Node.js runtime dependency for template rendering (Handlebars templates can be rendered from multiple languages via ports). EJS holds its position for quick server-rendered HTML in Express applications where the full framework overhead of Next.js or Nuxt is unnecessary — internal admin panels, webhook processors, and simple API documentation pages fit this profile well. eta is growing in use for situations where you need EJS-style syntax but in a browser or edge context — its 3KB bundle and browser compatibility make it the only viable choice when template rendering must happen client-side without shipping a full SSR framework. For any new user-facing web application where React, Vue, or Svelte is feasible, a template engine adds friction without benefit; the component model and hot module replacement of modern frameworks have made traditional template engines obsolete for interactive applications.

Methodology

Download data from npm registry (weekly average, February 2026). Feature comparison based on handlebars v4.x, ejs v3.x, and eta v3.x.

Compare templating and developer tool packages on PkgPulse →

See also: pm2 vs node:cluster vs tsx watch and h3 vs polka vs koa 2026, better-sqlite3 vs libsql vs sql.js.

The 2026 JavaScript Stack Cheatsheet

One PDF: the best package for every category (ORMs, bundlers, auth, testing, state management). Used by 500+ devs. Free, updated monthly.