Skip to main content

🎨 Template Engines in Express

Not every page should be built in the browser. Template engines let your server merge data with reusable HTML templates and ship a fully-rendered page β€” great for SEO, fast first paint, and content-heavy sites. This lesson compares EJS, Pug, and Handlebars and shows you how to wire them into Express.

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Explain what a template engine does and when server rendering beats client rendering
  • Configure a view engine in Express with app.set() and render pages with res.render()
  • Write templates in EJS, Pug, and Handlebars and compare their styles
  • Reuse markup with partials, layouts, inheritance, and helpers
  • Pass and safely escape dynamic data, and apply performance best practices like view caching

Estimated Time: 40–50 minutes  β€’  Difficulty: Intermediate

Hands-on: Render a dynamic profile page with EJS, including a partial and a loop.

In This Lesson

What Is a Template Engine?

A template engine combines a static template file with dynamic data and produces finished HTML. You write the page structure once, leave placeholders for the parts that change, and the engine fills them in at request time before sending the result to the browser.

How a template engine produces HTML A template file and dynamic data both feed into the template engine, which outputs generated HTML that is sent to the client browser. Template file structure + placeholders Dynamic data user, posts, … Template engine Generated HTML β†’ client browser
Figure 1 β€” Template + data go in; finished HTML comes out. The same template renders endless pages by changing the data.
🧁 The bakery analogy. The template is a cookie cutter β€” it defines the shape. The data is the dough. The engine is the baker pressing one into the other. Change the dough (data) and one cutter (template) yields endlessly different cookies (pages).

When to Render on the Server

Modern apps often render in the browser with React or Vue, but server-side templates remain the right tool in many cases. The core benefit is that the browser receives complete HTML immediately β€” no waiting for JavaScript to fetch data and build the DOM.

Reach for server templates when…Reach for client rendering when…
SEO matters (blogs, marketing, docs)The UI is highly interactive (dashboards, editors)
Content is mostly static or read-heavyState changes constantly without full reloads
Fast first contentful paint is a priorityYou're building a single-page app feel
You want it to work without JavaScriptYou already ship a rich JS framework
Generating HTML emails or admin panelsThe frontend and API are cleanly separated

πŸ’‘ Hybrid is common

Many production apps render the initial page on the server for speed and SEO, then let client-side JavaScript "hydrate" it and take over interactions. Frameworks like Next.js formalize this pattern, but you can achieve a lightweight version by embedding initial data in a template and enhancing it with a small script.

Setting Up a View Engine

Express integrates template engines through two settings and one method:

  • app.set('view engine', 'ejs') β€” which engine to use.
  • app.set('views', './views') β€” where template files live (this is the default, so it's often optional).
  • res.render('name', data) β€” render a template with data.
const express = require('express');
const app = express();

app.set('view engine', 'ejs');
app.set('views', './views');

app.get('/', (req, res) => {
  res.render('index', {
    title: 'Home Page',
    message: 'Welcome to our website!',
    items: ['Item 1', 'Item 2', 'Item 3']
  });
});

app.listen(3000, () => console.log('Listening on 3000'));

Install the engine first. Most engines are plain npm packages, and Express auto-requires them when named as the view engine:

npm install ejs          # EJS
npm install pug          # Pug
npm install express-handlebars   # Handlebars
npm install nunjucks     # Nunjucks

Handlebars needs an explicit engine registration because it supports layouts and helpers:

const express = require('express');
const { engine } = require('express-handlebars');
const app = express();

app.engine('handlebars', engine({
  defaultLayout: 'main',
  helpers: {
    formatDate: (d) => new Date(d).toLocaleDateString(),
    uppercase: (t) => String(t).toUpperCase()
  }
}));
app.set('view engine', 'handlebars');

EJS vs Pug vs Handlebars

The three most popular Express engines represent three philosophies. The clearest way to feel the difference is to render the same page in each.

EngineStylePhilosophyBest for
EJSHTML with embedded JS tagsFull JavaScript in templatesTeams who know HTML+JS and want flexibility
PugIndentation-based, no closing tagsTerse, whitespace-significantComplex nested layouts, less typing
HandlebarsCurly-brace {{ }}"Logic-less" β€” strict separationStrict MVC, non-technical template editors

The same header + list in each engine

EJS

<h1><%= title %></h1>
<% if (user) { %>
  <p>Welcome, <%= user.name %>!</p>
<% } else { %>
  <p>Please log in.</p>
<% } %>
<ul>
  <% items.forEach(item => { %>
    <li><%= item %></li>
  <% }); %>
</ul>

Pug

h1= title
if user
  p Welcome, #{user.name}!
else
  p Please log in.
ul
  each item in items
    li= item

Handlebars

<h1>{{title}}</h1>
{{#if user}}
  <p>Welcome, {{user.name}}!</p>
{{else}}
  <p>Please log in.</p>
{{/if}}
<ul>
  {{#each items}}
    <li>{{this}}</li>
  {{/each}}
</ul>

πŸ“– EJS output tags

<% %> runs JavaScript (no output). <%= %> outputs a value escaped (safe). <%- %> outputs unescaped HTML (use only with trusted content). <%- include('partials/header') %> pulls in a partial.

Partials, Layouts & Helpers

The real value of templates is not repeating yourself. Each engine offers a way to share headers, footers, and components across pages.

EJS: includes (partials)

<%- include('partials/header', { title: title }) %>

<main>
  <h2><%= message %></h2>
</main>

<%- include('partials/footer') %>

You can pass data into a partial β€” here the header receives its own title. This keeps navigation and boilerplate in one file.

Pug: template inheritance

Pug goes further with extends and named blocks. A layout defines slots; a page fills them:

//- layout.pug
doctype html
html
  head
    title #{title} - My Site
    block styles
  body
    block content

//- home.pug
extends layout
block styles
  link(rel="stylesheet", href="/css/home.css")
block content
  h2 Welcome!
  p= message

Handlebars: layouts, partials & helpers

With express-handlebars, a defaultLayout wraps every page (its content lands in {{{body}}}), partials are pulled with {{> name}}, and helpers add reusable logic:

<!-- layouts/main.handlebars -->
<body>
  {{> navigation}}
  <main>{{{body}}}</main>
  <footer>&copy; {{currentYear}}</footer>
</body>
// helper registered at engine setup
helpers: {
  formatPrice: (n) => Number(n).toFixed(2),
  currentYear: () => new Date().getFullYear()
}
// used as: ${{formatPrice product.price}}

βœ… Keep logic out of templates

Helpers exist so templates stay about presentation. Any real computation β€” filtering, aggregation, formatting rules β€” belongs in the controller or a helper, not tangled inside the markup.

Passing & Escaping Data

Data reaches a template through the object you pass to res.render(). The controller's job is to prepare that data β€” fetch it, format it, and add contextual flags β€” so the template can stay simple.

app.get('/profile/:username', async (req, res, next) => {
  try {
    const user = await User.findOne({ username: req.params.username });
    if (!user) {
      return res.status(404).render('error', {
        title: 'User Not Found',
        message: `No user: ${req.params.username}`
      });
    }
    const posts = await Post.find({ userId: user._id })
      .sort({ createdAt: -1 })
      .limit(5);

    res.render('profile', {
      title: `${user.displayName}'s Profile`,
      user: {
        displayName: user.displayName,
        bio: user.bio,
        joinDate: user.createdAt
      },
      posts,
      isOwner: req.user?.id === user._id.toString()  // contextual flag
    });
  } catch (err) {
    next(err);
  }
});

Notice isOwner: the controller computes it once and the template just checks a boolean to decide whether to show "Edit" buttons. That's the pattern β€” decisions in the controller, display in the view.

⚠️ Escape by default to prevent XSS

User-supplied text must be HTML-escaped or an attacker can inject <script> tags. EJS <%= %> and Handlebars {{ }} escape automatically. The unescaped forms β€” EJS <%- %> and Handlebars {{{ }}} β€” must be reserved for HTML you generated and trust, never raw user input.

Performance: cache compiled views

Compiling a template on every request is wasteful. In production, enable view caching so each template is compiled once and reused:

app.set('view cache', process.env.NODE_ENV === 'production');

Express enables this automatically when NODE_ENV is production, but setting it explicitly documents the intent.

Hands-on Exercise

πŸ‹οΈ Render a dynamic profile page with EJS

Objective: render a profile view that uses a partial, a conditional, and a loop.

Instructions:

  1. Install EJS and set it as the view engine. Create a views/ folder.
  2. Add views/partials/header.ejs containing an <h1> that outputs a title.
  3. Add views/profile.ejs that includes the header, shows the user's name, and lists their posts with a forEach loop.
  4. Show "This is your profile" only when an isOwner flag is true.
  5. Add a route GET /profile that renders the view with mock data and start the server.
πŸ’‘ Hint

Use <%= %> for escaped output and <%- include('partials/header', { title }) %> to pull in the header with its own title. Guard the loop with <% if (posts.length) { %> so an empty list renders cleanly.

βœ… Sample solution
<!-- views/partials/header.ejs -->
<header><h1><%= title %></h1></header>
<!-- views/profile.ejs -->
<%- include('partials/header', { title: user.name + "'s Profile" }) %>

<p>Name: <%= user.name %></p>
<% if (isOwner) { %>
  <p>This is your profile.</p>
<% } %>

<% if (posts.length) { %>
  <ul>
    <% posts.forEach(p => { %>
      <li><%= p.title %></li>
    <% }); %>
  </ul>
<% } else { %>
  <p>No posts yet.</p>
<% } %>
// app.js
const express = require('express');
const app = express();
app.set('view engine', 'ejs');

app.get('/profile', (req, res) => {
  res.render('profile', {
    user: { name: 'Ray' },
    posts: [{ title: 'First post' }, { title: 'Second post' }],
    isOwner: true
  });
});

app.listen(3000, () => console.log('Listening on 3000'));

🎯 Quick Quiz

Question 1: Which Express method renders a template with data?

Question 2: In EJS, which tag outputs a value escaped and safe against XSS?

Question 3: When is a server-side template engine the better choice over client rendering?

Best Practices

βœ… Do

  • Prepare and format data in the controller; keep templates about presentation.
  • Extract shared markup into partials, layouts, or mixins.
  • Use the escaped output tag by default; reserve unescaped output for trusted HTML.
  • Enable view caching in production.
  • Pick one engine per project and learn its idioms well.

⚠️ Don't

  • Don't output raw user input unescaped β€” it's an XSS hole.
  • Don't bury heavy logic (filtering, math) inside templates.
  • Don't recompile templates per request in production.
  • Don't reach for server templates when a highly interactive SPA is the real need.

Summary & Quiz

πŸŽ‰ Key Takeaways

  • A template engine merges a template with data to produce HTML on the server.
  • Server rendering wins for SEO, fast first paint, and content-heavy or JS-optional pages.
  • Configure with app.set('view engine', …) and render with res.render(view, data).
  • EJS (HTML+JS), Pug (indentation), and Handlebars (logic-less) suit different teams.
  • Reuse markup with partials/layouts/helpers, escape user data by default, and cache views in production.

πŸ“š Further Reading

πŸš€ What's Next?

You can now render dynamic HTML views. Next we shift from serving pages to serving data: building RESTful APIs with Express β€” resource design, status codes, and JSON responses that any frontend can consume.

πŸŽ‰ Views mastered!

Your server can now speak both HTML and, next, clean JSON APIs.