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
Security: Cross-Site Scripting and Output Escaping
The primary security concern with server-side template engines is cross-site scripting (XSS) — rendering user-supplied content as raw HTML without escaping it. Each engine handles output escaping differently, and the defaults matter more than the options.
Nunjucks escapes HTML by default. When you write {{ user.name }}, Nunjucks converts <, >, &, ", and ' to their HTML entities. If user.name is <script>alert(1)</script>, the template outputs <script>alert(1)</script> — safe for browsers. To render raw HTML intentionally, you use {{ user.bio | safe }}, making the unsafe operation visible in the template. This opt-in-to-danger model is the safest default.
Handlebars also escapes by default. {{user.name}} is HTML-escaped; {{{user.bio}}} renders raw HTML (triple-brace is the unsafe form). The visual distinction between double and triple braces is immediately visible in code review. EJS takes the opposite approach: <%= value %> escapes by default, but <%- value %> renders raw HTML with no escaping. The single-character difference between = and - is easy to overlook in code review, particularly in complex templates with many output tags. EJS's unsafe operator is the most common source of XSS vulnerabilities in Node.js applications — developers copy-paste a safe <%= %> block, change it to <%- %> for one case, and forget that the content being rendered is user-controlled. Handlebars and Nunjucks are meaningfully safer in large codebases where multiple developers edit templates.
Transactional Email: Where Template Engines Still Shine
Even in a React-dominated landscape, server-side template engines see their heaviest real-world use in email generation. Email clients cannot execute JavaScript, so JSX is off the table — you need a template engine that renders HTML strings on the server. Handlebars is the dominant choice here because its logic-less philosophy maps well to the strict constraints of email HTML: no complex expressions, precompile templates once at startup, and render with clean data objects. Libraries like nodemailer and @sendgrid/mail accept raw HTML strings, so you call Handlebars.compile(templateSource)(data) and pass the result directly.
EJS works for email but introduces risk — a junior developer can accidentally embed a database query or a console.log inside a <% %> tag that goes unreviewed. Nunjucks is rarely used for email because the async filter support, while powerful, adds complexity for a task that should be simple. A practical pattern is to keep layouts minimal: a single base template with a content block, a few conditionals for whether the user has a confirmed account or pending action, and inline CSS (since email clients strip <style> tags). Handlebars' {{#if}} / {{#each}} covers 95% of email logic without tempting developers to over-engineer.
Static Site Generation and Build-Time Rendering
Beyond server-rendered responses, all three template engines work well for static site generation — rendering HTML files at build time and serving them as static assets. This pattern reduces server costs and improves performance for content that doesn't change per-request: documentation sites, marketing pages, API references, and blog content.
Nunjucks is particularly well-suited for static site generation because its template inheritance model maps naturally to multi-page site structures with shared layouts. Tools like Eleventy (11ty) use Nunjucks as a first-class template language, and the {% extends %} / {% block %} pattern produces maintainable multi-page sites without repetition. Eleventy's Nunjucks integration supports async filters, global data files, and computed data — features that make it a complete static site framework when combined with Nunjucks templating.
EJS's popularity in static site contexts comes from its zero-learning-curve: developers familiar with JavaScript can write EJS templates immediately, and the full power of JavaScript is available for data transformation during build. Handlebars sees less adoption in modern static site generation because newer static site frameworks (Astro, Eleventy, Hugo) have adopted more capable template syntaxes. However, Handlebars remains common for generating static files from templates in CI/CD pipelines — configuration file generation, documentation from data sources, and report generation — where its precompilation advantage reduces build time.
Template Caching and Production Performance
All three engines cache compiled templates, but they handle it differently enough to matter in production. EJS compiles a template string to a JavaScript function on first render and caches it by filename. In high-throughput environments (thousands of requests per second), the cache eliminates repetitive compilation, so first-render latency is the only penalty. You can pre-warm the cache on startup by calling ejs.renderFile once per template during initialization.
Handlebars has the most sophisticated precompilation story: Handlebars.precompile() converts a template to a JavaScript string that can be saved to disk and loaded as a module — no compilation at runtime at all. This is the same approach used by projects that serve thousands of distinct email variants from a template registry. Nunjucks uses an internal Environment object with a FileSystemLoader that respects a noCache flag; in development you pass noCache: true so file changes reload immediately, while production uses the default caching behavior. Nunjucks also supports a precompile CLI to generate JavaScript from .njk files, enabling the same zero-runtime-compilation approach as Handlebars. For high-frequency render paths (OG image captions, PDF reports), precompilation or explicit cache warming is worth implementing regardless of which engine you choose.
Migrating Between Template Engines
Migrating from EJS to Nunjucks is the most common upgrade path. EJS templates use <%= value %> and raw JavaScript <% if (x) { %>, while Nunjucks uses {{ value }} and {% if x %}. The semantic intent is nearly identical, so migration is usually a search-and-replace pass followed by extracting logic from embedded JavaScript into Nunjucks filters or macros. The biggest structural change is adopting template inheritance: EJS uses include to compose layouts, which means every page template must manually include a header and footer partial. Nunjucks' extends / block system inverts this — the base template defines the structure and child templates fill in named slots, which produces significantly less repetition in large applications.
Migrating from Handlebars to Nunjucks is less common but occurs when teams need template inheritance or async filters. The {{helper arg}} pattern maps to {{ arg | filter }} in Nunjucks, though block helpers ({{#ifGreaterThan}}) require rewriting as Nunjucks macros or custom tags. Going the other direction — from EJS to Handlebars — is mostly a discipline choice to enforce logic-less templates, and usually involves extracting inline JavaScript into registered helpers. The migration is straightforward syntactically but requires identifying and isolating business logic that was previously embedded in view files.
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 →
See also: pm2 vs node:cluster vs tsx watch and h3 vs polka vs koa 2026, better-sqlite3 vs libsql vs sql.js.