Handlebars vs EJS vs eta: Server-Side Template Engines in Node.js (2026)
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)
Download Trends
| Package | Weekly Downloads | Bundle Size | Logic-less | Partials | TypeScript |
|---|---|---|---|---|---|
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
| Feature | Handlebars | EJS | eta |
|---|---|---|---|
| Bundle size | ~180KB | ~40KB | ~3KB |
| Logic-less | ✅ | ❌ | ❌ |
| Embedded JS | ❌ | ✅ | ✅ |
| Partials/includes | ✅ | ✅ include | ✅ |
| Custom helpers | ✅ | ❌ (use helpers) | ✅ |
| TypeScript | ✅ @types | ✅ @types | ✅ Native |
| Browser support | ✅ | ❌ | ✅ |
| Performance | Moderate | Good | Fastest |
| 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 →