How to use template engines in Express.js?
Template engines in Express.js generate dynamic HTML on the server by merging a template file with a data object. Instead of writing res.send('<h1>' + name + '</h1>'), you keep markup in .ejs or .pug files and call res.render('view', data) from your route handlers.
Theory
TL;DR
- Template = a file with placeholders;
res.render('view', data)fills them and sends HTML to the browser - Think of it like Mad Libs: the template is the story with blanks, data fills them at runtime
- EJS is the most common starting point because it is just HTML with
<% %>tags - Use a template engine when you send HTML; skip it for JSON APIs or React/Vue frontends
- Express does not bundle any engine, so
npm install ejs(or pug, handlebars) comes first
Quick setup
npm install ejs// app.js
const express = require('express');
const path = require('path');
const app = express();
app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, 'views')); // absolute path avoids lookup errors
app.get('/', (req, res) => {
res.render('home', { title: 'Hello', users: ['Alice', 'Bob'] });
});
app.listen(3000);<!-- views/home.ejs -->
<h1><%= title %></h1>
<ul>
<% users.forEach(user => { %>
<li><%= user %></li>
<% }); %>
</ul>
<!-- Output: <h1>Hello</h1><ul><li>Alice</li><li>Bob</li></ul> -->Express loads the .ejs file, compiles it to a Node.js function, injects the data, and sends back a finished HTML string. The compiled function stays in memory, so repeated requests skip the disk read.
Popular engines
| Engine | File | Style | Best for |
|---|---|---|---|
| EJS | .ejs | HTML with <% %> tags | Quick starts, HTML-first devs |
| Pug | .pug | Indentation-based, no closing tags | Concise, component-like markup |
| Handlebars | .hbs | Logic-less {{ }} | Simple data display |
| Nunjucks | .njk | Jinja2-like syntax | Complex loops and filters |
EJS is the most common for juniors because the syntax looks like plain HTML. Pug reduces boilerplate. Handlebars keeps templates clean by discouraging logic inside them. None is objectively best. Pick based on what your team already knows.
EJS tag reference
| Tag | What it does |
|---|---|
<%= value %> | Outputs the value, HTML-escaped |
<%- value %> | Outputs raw HTML, unescaped |
<% code %> | Runs JavaScript, no output |
<%- include('partial') %> | Injects another template file |
The <%= %> vs <%- %> difference matters for security. User-generated content always goes through <%= %> because it escapes <, >, and &. The <%- %> tag renders raw HTML, which opens XSS if you pass untrusted data through it.
When to use
- Blog or dashboard with data from a database: template engine fits well
- API that returns JSON: skip it, use
res.json() - SPA with React or Vue on the frontend: skip it, the client handles rendering
- Email or HTML report generated on demand: template engine works here too
- Quick prototype where you want to see HTML fast: EJS, zero learning curve
Partials and global variables
EJS supports partials through <%- include('header') %>. This lets you reuse navigation, footers, and page frames without copying HTML across files.
For data that every template needs, like the app name or current year, use app.locals:
app.locals.siteName = 'MyApp';
app.locals.year = new Date().getFullYear();
// Now <%= siteName %> works in every template without passing it each timeFor per-request data such as the logged-in user, use res.locals in middleware:
app.use((req, res, next) => {
res.locals.currentUser = req.user || null;
next();
});Most admin panels I have seen use exactly this pattern: app.locals for static config, res.locals for auth context, and per-render data only for what changes between routes.
How Express handles rendering
res.render('dashboard', data) does this: loads views/dashboard.ejs from disk on the first request, compiles the template to a JavaScript function, caches that function in memory, calls it with the merged locals (your data plus app.locals plus res.locals), and sends the result. In NODE_ENV=production caching is always on. In development, Express recompiles on each request so you see changes without restarting.
Common mistakes
Not installing the engine package. app.set('view engine', 'ejs') just tells Express what to look for. If you skip npm install ejs, you get Cannot find module 'ejs' at runtime. Nothing in Express bundles it for you.
Using a relative path for views.
// Breaks when the process starts from a different directory
app.set('views', './views');
// Always use this
app.set('views', path.join(__dirname, 'views'));Using <%- %> with user input.
<!-- XSS: renders <script>alert(1)</script> as actual HTML -->
<li><%- user.name %></li>
<!-- Safe: outputs <script>alert(1)</script> as visible text -->
<li><%= user.name %></li>Forgetting to pass data. res.render('users') with no second argument means the template gets no variables. Accessing <%= users.length %> throws Cannot read properties of undefined.
Mixing res.render() and res.json() in one route. Pick one per endpoint. Calling both causes a Cannot set headers after they are sent error.
Real-world usage
- Ghost (blog platform) uses server-rendered templates for post pages
- Handlebars powers Shopify themes, rendering storefront product data per request
- Mozilla docs site uses Nunjucks for complex filter logic, similar to Python Flask
- Express + EJS is a common stack for admin dashboards and bootcamp projects where SEO and fast initial load matter more than interactivity
Follow-up questions
Q: How does caching work in production?
A: In NODE_ENV=production, Express caches compiled template functions after the first render. In development it recompiles every time so edits show immediately. You do not configure this manually.
Q: What is the difference between <% %>, <%= %>, and <%- %> in EJS?
A: <% %> runs JavaScript with no output. <%= %> outputs a value with HTML escaping. <%- %> outputs raw HTML without escaping. For anything from a user or a database, always use <%= %>.
Q: How does res.render differ from res.send?
A: res.render compiles a template file with data and sends the result. res.send takes whatever string or buffer you pass directly. No template file involved.
Q: Can two routes render the same template without data leaking between requests?
A: Yes. Express caches the compiled function but each call executes it with fresh locals. Data from one request never bleeds into another. Anything on app.locals is global, so only put truly static values there.
Q: Pug or EJS for performance under high traffic?
A: After the first compile both become JavaScript functions and perform about the same. Pug's initial parse is slightly slower but that difference disappears after caching. This is not a meaningful factor when choosing an engine.
Examples
Basic EJS page with a user list
// app.js
const express = require('express');
const path = require('path');
const app = express();
app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, 'views'));
app.get('/users', (req, res) => {
const users = [
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' },
];
res.render('users', { title: 'User list', users });
});
app.listen(3000);<!-- views/users.ejs -->
<h1><%= title %></h1>
<ul>
<% users.forEach(user => { %>
<li><a href="/users/<%= user.id %>"><%= user.name %></a></li>
<% }); %>
</ul>
<% if (users.length === 0) { %>
<p>No users found.</p>
<% } %>Data from the route flows into the template. The forEach renders one <li> per user. The empty-state check keeps the page useful when the array is empty.
Dashboard with partials and auth middleware
// app.js
app.locals.siteName = 'MyApp'; // available in every template
app.use((req, res, next) => {
res.locals.currentUser = req.user || null;
next();
});
app.get('/dashboard', async (req, res) => {
const users = await db.query('SELECT * FROM users LIMIT 10');
res.render('dashboard', {
recentUsers: users.rows,
stats: { total: users.rows.length },
});
});<!-- views/partials/header.ejs -->
<header>
<span><%= siteName %></span>
<% if (currentUser) { %>
<span>Hello, <%= currentUser.name %></span>
<% } %>
</header>
<!-- views/dashboard.ejs -->
<%- include('partials/header') %>
<p>Total users: <%= stats.total %></p>
<table>
<% recentUsers.forEach(u => { %>
<tr>
<td><%= u.name %></td>
<td><%= u.email %></td>
</tr>
<% }); %>
</table>res.locals.currentUser set in middleware is available inside the partial without passing it explicitly. app.locals.siteName set once at startup appears in every template. This is how real Express apps avoid repetitive data passing across routes.
Safe vs unsafe output
// Route that receives user-generated content
app.get('/comment', (req, res) => {
const comment = {
author: 'Bob',
text: '<script>alert("XSS")</script> Nice post!',
};
res.render('comment', { comment });
});<!-- views/comment.ejs -->
<strong><%= comment.author %></strong>
<!-- Safe output -->
<p><%= comment.text %></p>
<!-- Browser shows: <script>alert("XSS")</script> Nice post! -->
<!-- The angle brackets are escaped, no script runs -->
<!-- Dangerous output -->
<p><%- comment.text %></p>
<!-- Browser executes the script tag --><%= %> is the default choice for every value that comes from a user, a form, or a database. <%- %> is for trusted content only, like HTML you construct yourself on the server.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.