π¨ 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 withres.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.
π§ 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-heavy | State changes constantly without full reloads |
| Fast first contentful paint is a priority | You're building a single-page app feel |
| You want it to work without JavaScript | You already ship a rich JS framework |
| Generating HTML emails or admin panels | The 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.
| Engine | Style | Philosophy | Best for |
|---|---|---|---|
| EJS | HTML with embedded JS tags | Full JavaScript in templates | Teams who know HTML+JS and want flexibility |
| Pug | Indentation-based, no closing tags | Terse, whitespace-significant | Complex nested layouts, less typing |
| Handlebars | Curly-brace {{ }} | "Logic-less" β strict separation | Strict 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>© {{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:
- Install EJS and set it as the view engine. Create a
views/folder. - Add
views/partials/header.ejscontaining an<h1>that outputs atitle. - Add
views/profile.ejsthat includes the header, shows the user's name, and lists theirpostswith aforEachloop. - Show "This is your profile" only when an
isOwnerflag is true. - Add a route
GET /profilethat 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 withres.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.