Skip to main content

Handlebars vs EJS vs eta: Server-Side Template Engines in Node.js (2026)

·PkgPulse Team

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

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 →

Comments

Stay Updated

Get the latest package insights, npm trends, and tooling tips delivered to your inbox.