ejs vs handlebars vs nunjucks: Server-Side Templating in Node.js (2026)
TL;DR
ejs is the simplest server-side template engine — plain JavaScript in your HTML templates, no new syntax to learn. Handlebars enforces logic-less templates — keeps presentation separate from business logic with a minimal helper system. Nunjucks (by Mozilla) is the most powerful — Jinja2-inspired, supports template inheritance, macros, async filters, and autoescaping. In 2026: ejs for quick server-rendered pages, Handlebars for logic-less email or HTML templates, Nunjucks for complex layouts with inheritance.
Key Takeaways
- ejs: ~15M weekly downloads —
<%= %>syntax, raw JavaScript, zero learning curve - handlebars: ~12M weekly downloads —
{{}}syntax, logic-less, custom helpers, precompilation - nunjucks: ~1.5M weekly downloads — Jinja2 syntax, block inheritance, macros, async support
- ejs lets you write ANY JavaScript in templates — powerful but dangerous (no guardrails)
- Handlebars intentionally restricts logic — forces clean separation of data and presentation
- Nunjucks template inheritance (
{% extends "base.html" %}) is the most maintainable for complex layouts
Do You Still Need Server-Side Templating in 2026?
Yes, for:
- Transactional emails (order confirmation, password reset)
- PDF generation (invoices, reports)
- Server-rendered pages (admin dashboards, multi-page apps)
- Static site generators
- CLI output formatting
Usually not needed for:
- React / Next.js / SvelteKit apps (use JSX or Svelte templates)
- API-only backends (return JSON, not HTML)
- Single-page applications
ejs
ejs — embedded JavaScript templates:
Basic syntax
<!-- views/packages.ejs -->
<!DOCTYPE html>
<html>
<head>
<title><%= title %></title>
</head>
<body>
<h1><%= title %></h1>
<%# This is a comment %>
<% if (packages.length > 0) { %>
<ul>
<% packages.forEach(pkg => { %>
<li>
<strong><%= pkg.name %></strong> — v<%= pkg.version %>
<span>Downloads: <%= pkg.downloads.toLocaleString() %></span>
</li>
<% }) %>
</ul>
<% } else { %>
<p>No packages found.</p>
<% } %>
</body>
</html>
Tag reference
<%= value %> Output, HTML-escaped (safe)
<%- value %> Output, unescaped (dangerous — use for trusted HTML only)
<% code %> Execute JavaScript (no output)
<%# comment %> Comment (not in output)
<%- include('header') %> Include another template
Express integration
import express from "express"
const app = express()
// Set ejs as the view engine:
app.set("view engine", "ejs")
app.set("views", "./src/views")
app.get("/packages", async (req, res) => {
const packages = await PackageService.getPopular()
res.render("packages", {
title: "Popular Packages",
packages,
})
})
Partials (includes)
<!-- views/partials/header.ejs -->
<header>
<nav>
<a href="/">Home</a>
<a href="/packages">Packages</a>
</nav>
</header>
<!-- views/packages.ejs -->
<!DOCTYPE html>
<html>
<body>
<%- include("partials/header") %>
<main>
<h1><%= title %></h1>
<!-- content -->
</main>
<%- include("partials/footer") %>
</body>
</html>
Handlebars
Handlebars — logic-less templates:
Basic syntax
<!-- views/packages.hbs -->
<!DOCTYPE html>
<html>
<head>
<title>{{title}}</title>
</head>
<body>
<h1>{{title}}</h1>
{{! This is a comment }}
{{#if packages.length}}
<ul>
{{#each packages}}
<li>
<strong>{{this.name}}</strong> — v{{this.version}}
<span>Downloads: {{formatNumber this.downloads}}</span>
</li>
{{/each}}
</ul>
{{else}}
<p>No packages found.</p>
{{/if}}
</body>
</html>
Custom helpers
import Handlebars from "handlebars"
// Register a helper:
Handlebars.registerHelper("formatNumber", (num: number) => {
return num.toLocaleString()
})
Handlebars.registerHelper("timeAgo", (date: string) => {
const diff = Date.now() - new Date(date).getTime()
const days = Math.floor(diff / 86400000)
if (days === 0) return "today"
if (days === 1) return "yesterday"
return `${days} days ago`
})
// Block helper:
Handlebars.registerHelper("ifGreaterThan", function (a, b, options) {
return a > b ? options.fn(this) : options.inverse(this)
})
<!-- Using custom helpers: -->
<p>Last updated: {{timeAgo package.updatedAt}}</p>
{{#ifGreaterThan package.healthScore 80}}
<span class="badge-green">Healthy</span>
{{else}}
<span class="badge-yellow">Needs attention</span>
{{/ifGreaterThan}}
Express integration
import express from "express"
import { engine } from "express-handlebars"
const app = express()
app.engine("hbs", engine({
extname: ".hbs",
defaultLayout: "main", // views/layouts/main.hbs
layoutsDir: "src/views/layouts",
partialsDir: "src/views/partials",
helpers: {
formatNumber: (n: number) => n.toLocaleString(),
},
}))
app.set("view engine", "hbs")
app.set("views", "./src/views")
Layouts
<!-- views/layouts/main.hbs -->
<!DOCTYPE html>
<html>
<head>
<title>{{title}} — PkgPulse</title>
</head>
<body>
{{> header}}
<main>
{{{body}}}
</main>
{{> footer}}
</body>
</html>
<!-- views/packages.hbs (uses layout automatically) -->
<h1>{{title}}</h1>
<ul>
{{#each packages}}
<li>{{this.name}}</li>
{{/each}}
</ul>
Email templates (common use case)
import Handlebars from "handlebars"
const template = Handlebars.compile(`
<h1>Welcome to PkgPulse, {{name}}!</h1>
<p>You signed up on {{formatDate signupDate}}.</p>
{{#if hasPackages}}
<p>You're tracking {{packages.length}} packages:</p>
<ul>
{{#each packages}}
<li>{{this}}</li>
{{/each}}
</ul>
{{/if}}
`)
const html = template({
name: "Royce",
signupDate: "2026-01-15",
hasPackages: true,
packages: ["react", "next", "typescript"],
})
Nunjucks
Nunjucks — rich templating (Mozilla):
Basic syntax
{# views/packages.njk #}
{% extends "base.njk" %}
{% block title %}{{ title }}{% endblock %}
{% block content %}
<h1>{{ title }}</h1>
{% if packages | length > 0 %}
<ul>
{% for pkg in packages %}
<li>
<strong>{{ pkg.name }}</strong> — v{{ pkg.version }}
<span>Downloads: {{ pkg.downloads | formatNumber }}</span>
</li>
{% endfor %}
</ul>
{% else %}
<p>No packages found.</p>
{% endif %}
{% endblock %}
Template inheritance (killer feature)
{# views/base.njk — base layout #}
<!DOCTYPE html>
<html>
<head>
<title>{% block title %}PkgPulse{% endblock %}</title>
{% block head %}{% endblock %}
</head>
<body>
{% include "partials/header.njk" %}
<main class="container">
{% block content %}{% endblock %}
</main>
{% block scripts %}{% endblock %}
</body>
</html>
{# views/packages.njk — extends base #}
{% extends "base.njk" %}
{% block title %}{{ title }} — PkgPulse{% endblock %}
{% block head %}
<link rel="stylesheet" href="/css/packages.css">
{% endblock %}
{% block content %}
<h1>{{ title }}</h1>
{# Page content here #}
{% endblock %}
{% block scripts %}
<script src="/js/packages.js"></script>
{% endblock %}
Macros (reusable components)
{# views/macros/components.njk #}
{% macro packageCard(pkg) %}
<div class="card">
<h3>{{ pkg.name }}</h3>
<p>{{ pkg.description | truncate(100) }}</p>
<div class="stats">
<span>⬇️ {{ pkg.downloads | formatNumber }}/wk</span>
<span>⭐ {{ pkg.stars | formatNumber }}</span>
<span>Score: {{ pkg.healthScore }}/100</span>
</div>
</div>
{% endmacro %}
{# Usage: #}
{% from "macros/components.njk" import packageCard %}
<div class="grid">
{% for pkg in packages %}
{{ packageCard(pkg) }}
{% endfor %}
</div>
Custom filters
import nunjucks from "nunjucks"
const env = nunjucks.configure("src/views", {
autoescape: true, // HTML-escape by default (secure)
express: app,
})
// Custom filters:
env.addFilter("formatNumber", (num: number) => num.toLocaleString())
env.addFilter("timeAgo", (date: string) => {
const days = Math.floor((Date.now() - new Date(date).getTime()) / 86400000)
return days === 0 ? "today" : `${days}d ago`
})
// Async filter:
env.addFilter("fetchBadge", async (name: string, callback) => {
const score = await HealthService.getScore(name)
callback(null, score > 80 ? "🟢" : "🟡")
}, true) // true = async
Express integration
import nunjucks from "nunjucks"
import express from "express"
const app = express()
nunjucks.configure("src/views", {
autoescape: true,
express: app,
watch: process.env.NODE_ENV === "development",
})
app.set("view engine", "njk")
app.get("/packages/:name", async (req, res) => {
const pkg = await PackageService.get(req.params.name)
res.render("package-detail", { pkg })
})
Feature Comparison
| Feature | ejs | Handlebars | Nunjucks |
|---|---|---|---|
| Syntax | <%= %> | {{ }} | {{ }} / {% %} |
| Logic in templates | ✅ Full JS | ❌ Helpers only | ✅ Expressions + filters |
| Template inheritance | ❌ | ❌ (layouts only) | ✅ (extends + block) |
| Macros | ❌ | ❌ | ✅ |
| Custom filters | ❌ (use JS) | Helpers | ✅ |
| Async support | ❌ | ❌ | ✅ |
| Autoescaping | Manual | ✅ | ✅ |
| Precompilation | ❌ | ✅ | ✅ |
| Learning curve | None (it's JS) | Low | Medium |
| Weekly downloads | ~15M | ~12M | ~1.5M |
When to Use Each
Choose ejs if:
- Quick server-rendered pages — zero learning curve
- Small projects, prototypes, admin panels
- Team already knows JavaScript (no new syntax)
- Simplicity over structure
Choose Handlebars if:
- Email templates — clean separation of data and presentation
- Logic-less templates are a priority (prevent spaghetti in views)
- Need precompiled templates for performance
- Designers or non-developers edit templates
Choose Nunjucks if:
- Complex layouts with template inheritance (
extends/block) - Need macros for reusable components
- Async operations in templates (database lookups, API calls)
- Coming from Python/Jinja2 — nearly identical syntax
Methodology
Download data from npm registry (weekly average, February 2026). Feature comparison based on ejs v3.x, handlebars v4.x, and nunjucks v3.x.
Compare templating and server-side rendering packages on PkgPulse →