Skip to main content

ejs vs handlebars vs nunjucks: Server-Side Templating in Node.js (2026)

·PkgPulse Team

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

FeatureejsHandlebarsNunjucks
Syntax<%= %>{{ }}{{ }} / {% %}
Logic in templates✅ Full JS❌ Helpers only✅ Expressions + filters
Template inheritance❌ (layouts only)✅ (extends + block)
Macros
Custom filters❌ (use JS)Helpers
Async support
AutoescapingManual
Precompilation
Learning curveNone (it's JS)LowMedium
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 →

Comments

Stay Updated

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