6.1 Why Templating Exists
Up to this point, everything we've built has sent back either plain strings or JSON. That works fine for APIs, where the client is another program that knows how to interpret structured data. But not every request wants JSON. Sometimes the client is a browser, and the browser wants HTML — styled, structured, interactive HTML.
You could generate that HTML with string concatenation in your route handlers. It would look like this:
app.get('/profile', (req, res) => {
const user = getUser(req.params.id);
res.send(`
<html>
<head><title>${user.name}'s Profile</title></head>
<body>
<h1>${user.name}</h1>
<p>Email: ${user.email}</p>
</body>
</html>
`);
});
It works. But it's fragile, hard to read, and almost impossible to maintain once the page grows beyond a dozen lines. You lose syntax highlighting. You lose linting. You lose the ability to reuse components. And if you ever need to display a list of users, you're writing map().join('') inside a template string.
Template engines solve this. They let you write HTML as HTML, with placeholders for dynamic data, and then render that template with actual values at request time . The template file stays static — it doesn't change between requests. The engine fills in the blanks and sends the result.
The Express documentation puts it simply: "A template engine enables you to use static template files in your application. At runtime, the template engine replaces variables in a template file with actual values, and transforms the template into an HTML file sent to the client" .
6.2 Configuring a View Engine
Setting up templating in Express takes three steps: install the engine, tell Express to use it, and create the templates.
Step 1: Install the Engine
npm install ejs
Or pug, or handlebars — whichever you prefer. Each engine is a separate npm package.
Step 2: Configure Express
const express = require('express');
const path = require('path');
const app = express();
app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'ejs');
The views setting tells Express where your template files live. It defaults to a views directory in your application root, but you can point it anywhere .
The view engine setting tells Express which engine to use. Once set, you don't need to load the engine module manually — Express handles that internally .
Step 3: Create and Render Templates
Create a file at views/index.ejs:
<!DOCTYPE html>
<html>
<head>
<title><%= title %></title>
</head>
<body>
<h1><%= heading %></h1>
<p>Welcome to <%= title %></p>
</body>
</html>
Then render it from a route:
app.get('/', (req, res) => {
res.render('index', {
title: 'My Express App',
heading: 'Hello from EJS!'
});
});
The res.render() method takes the template name (without the extension, since the engine is configured) and an object of data. Express passes that data to the template, the engine renders it, and the resulting HTML is sent to the client .
If you haven't set a view engine, you must include the file extension in the render call. With the engine configured, you can omit it .
6.3 The Major Template Engines
Express doesn't force a choice. It works with any engine that conforms to a simple convention: the engine must export a function named __express(filePath, options, callback), which res.render() calls internally . Most popular engines follow this convention, and for those that don't, the @ladjs/consolidate library maps them into compliance.
Three engines dominate in practice.
EJS (Embedded JavaScript)
EJS uses ordinary HTML with JavaScript embedded between special tags. If you know HTML and JavaScript, you already know 90% of EJS .
<!-- views/user.ejs -->
<h1>Hello, <%= username %>!</h1>
<p>Email: <%= user.email %></p>
<ul>
<% items.forEach(function(item) { %>
<li><%= item.name %></li>
<% }); %>
</ul>
The syntax is minimal: <%= %> outputs an escaped value, <%- %> outputs unescaped HTML, and <% %> runs logic without outputting anything.
EJS is the gentlest onramp. The learning curve is shallow, and the results are readable to anyone who knows HTML. The trade-off is that templates can become messy if you stuff too much logic into them — but that's a discipline problem, not a syntax problem.
Pug (formerly Jade)
Pug takes the opposite approach. It uses indentation-based syntax that compiles to HTML, stripping away the angle brackets and closing tags entirely .
//- views/user.pug
h1 Hello, #{username}!
p Email: #{user.email}
ul
each item in items
li= item.name
Pug is concise — the same template takes far fewer characters than in EJS or plain HTML. It also has built-in support for layouts and mixins (reusable components) . But the syntax is genuinely different from HTML, and the whitespace sensitivity means a stray space can break your template in ways that are hard to debug .
Express's application generator defaults to Pug . If you've ever generated an Express app with express-generator, the .pug files you saw came from that default.
Handlebars
Handlebars takes a logic-light approach. Templates are designed to be readable by non-programmers, with minimal JavaScript embedded .
<!-- views/user.handlebars -->
<h1>Hello, {{username}}!</h1>
<p>Email: {{user.email}}</p>
<ul>
{{#each items}}
<li>{{this.name}}</li>
{{/each}}
</ul>
Handlebars uses {{ }} for escaped output and {{{ }}} for unescaped. It's stricter than EJS — you can't run arbitrary JavaScript inside templates — which some teams see as a feature (it forces separation of concerns) and others see as a limitation.
Handlebars requires the express-handlebars package rather than raw handlebars, because the raw package doesn't follow Express's engine convention .
6.4 Passing Data to Templates
The second argument to res.render() is the data object. Whatever you put there becomes available in the template .
app.get('/user-profile', (req, res) => {
const user = {
name: 'Jane Doe',
email: 'jane@example.com',
address: {
street: '123 Main St',
city: 'Anytown',
zipcode: '12345'
},
hobbies: ['Reading', 'Hiking', 'Cooking']
};
res.render('profile', { user });
});
In the template, you access nested properties the same way you would in JavaScript:
<h1><%= user.name %>'s Profile</h1>
<p>Email: <%= user.email %></p>
<h2>Address:</h2>
<address>
<%= user.address.street %><br>
<%= user.address.city %>, <%= user.address.zipcode %>
</address>
<h2>Hobbies:</h2>
<ul>
<% user.hobbies.forEach(function(hobby) { %>
<li><%= hobby %></li>
<% }); %>
</ul>
Conditionals work the same way. Pass a boolean, check it in the template :
res.render('dashboard', { user: { name: 'Sarah', isAdmin: true } });
<% if (user.isAdmin) { %>
<div class="admin-panel">Admin Controls</div>
<% } %>
res.locals: Data for Every Template
Sometimes you have data that every template needs — the current user, the app name, the current year for a footer. Passing it to every res.render() call gets tedious. res.locals solves that.
res.locals is an object scoped to the current request. Anything you put there is automatically available to any template rendered during that request .
app.use((req, res, next) => {
res.locals.currentUser = req.user || null;
res.locals.appName = 'WinehouseLabs';
res.locals.currentYear = new Date().getFullYear();
next();
});
Now every template can use <%= appName %> and <%= currentYear %> without the route handler doing anything special .
6.5 Escaping and Security
This is the part that matters most, and the part most tutorials gloss over.
Every template engine worth using escapes output by default. That means if a user's name is <script>alert('xss')</script>, the template will render it as visible text, not as executable JavaScript. This prevents cross-site scripting (XSS) attacks .
But every engine also gives you a way to bypass escaping. And every bypass is a potential vulnerability.
| Engine | Escaped | Unescaped |
|---|---|---|
| EJS | <%= content %> | <%- content %> |
| Pug | #{content} or p= content | !{content} or p!= content |
| Handlebars | {{content}} | {{{content}}} |
The rule is simple: never use the unescaped form on user-controlled data . If you need to render HTML that came from a database, make sure that HTML was sanitized before it got there. If you can't guarantee that, escape it.
There's a related trap that catches people by surprise: passing the entire request query or body object directly to res.render() .
// DANGEROUS
app.get('/', (req, res) => {
res.render('index', req.query);
});
If a user sends ?name=<script>..., and your template uses the unescaped form somewhere, you've handed them a direct line into your page. The safer pattern is to explicitly extract and validate each value you need :
app.get('/', (req, res) => {
res.render('index', {
name: String(req.query.name || '').slice(0, 100)
});
});
Converting to a string isn't validation — it's just a guard against type errors. If the data is user-controlled, validate it properly.
6.6 Best Practices
The patterns that keep templating maintainable come down to a few principles :
Keep logic out of templates. Prepare data in the route handler, not in the template. If you're writing complex conditionals or calculations inside <% %>, that logic belongs in JavaScript. Templates should present data, not compute it.
Use layouts and partials. Headers, footers, navigation bars — these appear on every page. Don't duplicate them across every template. Use partials (<%- include('header') %> in EJS, include in Pug, {{> header}} in Handlebars) to reuse markup .
Organize your views directory. A typical structure looks like this :
views/
├── home.ejs
├── about.ejs
└── partials/
├── header.ejs
└── footer.ejs
Enable template caching in production. Express caches the compiled template itself, not the rendered output. This means the template file is read and parsed once, then reused for subsequent requests. It's a performance win with no downside in production, but you might disable it in development if you're editing templates frequently .
Handle errors with dedicated templates. When something goes wrong, render a proper error page rather than sending raw error text. A views/error.ejs template that takes an error object and displays a friendly message is standard practice .
6.7 Custom Template Engines
Express's engine system is open. If you want to build your own — or adapt an engine that doesn't follow the convention — you use app.engine() .
The method takes a file extension and a callback function:
const fs = require('fs');
app.engine('ntl', (filePath, options, callback) => {
fs.readFile(filePath, (err, content) => {
if (err) return callback(err);
const rendered = content.toString()
.replace('#title#', `<title>${options.title}</title>`)
.replace('#message#', `<h1>${options.message}</h1>`);
return callback(null, rendered);
});
});
app.set('views', './views');
app.set('view engine', 'ntl');
The callback receives the file path, the options object, and a Node-style callback. Read the file, do whatever transformation you want, and pass the result back. Express handles the rest .
This is also how consolidate works under the hood — it wraps engines that don't export __express and presents them in the shape Express expects.