Episode 3 — NodeJS MongoDB Backend Architecture / 3.5 — Template Engine EJS
3.5.c — EJS Syntax Deep Dive
In one sentence: EJS has exactly five tag types you need to memorize —
<%= %>for safe output,<%- %>for raw HTML,<% %>for logic,<%# %>for comments, and<%- include() %>for partials — and knowing when to use each one is the difference between a secure, maintainable template and a broken one.
Navigation: <- 3.5.b — Setting Up EJS | 3.5.d — Control Flow in EJS ->
1. The Five EJS Tag Types — Complete Reference
| Tag | Name | Purpose | Output in HTML? |
|---|---|---|---|
<%= expr %> | Escaped output | Prints value with HTML entities escaped | Yes (safe) |
<%- expr %> | Unescaped output | Prints raw HTML — no escaping | Yes (dangerous with user input) |
<% code %> | Scriptlet | Runs JS code — no output | No |
<%# text %> | Comment | EJS comment — stripped from output | No |
<%- include('path') %> | Include | Inserts another EJS file inline | Yes (contents of included file) |
2. <%= expression %> — Escaped Output (Your Default)
This is the tag you will use 90% of the time. It evaluates a JavaScript expression and inserts the result into the HTML, escaping special characters to prevent XSS attacks.
Express route:
app.get('/profile', (req, res) => {
res.render('profile', {
username: 'Arjun',
bio: '<script>alert("hacked!")</script>',
score: 42
});
});
views/profile.ejs:
<h1>Profile: <%= username %></h1>
<p>Bio: <%= bio %></p>
<p>Score: <%= score %></p>
<p>Score doubled: <%= score * 2 %></p>
Rendered HTML (what the browser receives):
<h1>Profile: Arjun</h1>
<p>Bio: <script>alert("hacked!")</script></p>
<p>Score: 42</p>
<p>Score doubled: 84</p>
The <script> tag in bio was escaped — the browser shows it as text, not as executable code. This is XSS protection.
What <%= %> escapes:
| Character | Escaped To |
|---|---|
& | & |
< | < |
> | > |
" | " |
' | ' |
Rule of thumb: Always use <%= %> for any data that could come from user input — form fields, database records, query parameters, API responses.
3. <%- expression %> — Unescaped (Raw) Output
This tag outputs the expression exactly as-is, with no escaping. HTML tags in the value become real HTML elements.
Express route:
app.get('/page', (req, res) => {
res.render('page', {
content: '<h2>Welcome!</h2><p>This is <strong>bold</strong> text.</p>'
});
});
views/page.ejs:
<div class="article">
<%- content %>
</div>
Rendered HTML:
<div class="article">
<h2>Welcome!</h2><p>This is <strong>bold</strong> text.</p>
</div>
The HTML tags are real — the browser renders them as formatted content.
When to use <%- %>:
| Safe Uses | Dangerous Uses |
|---|---|
Including partials: <%- include('header') %> | User-submitted comments |
| Rendering trusted HTML from a CMS admin panel | Search query display |
| Pre-sanitized rich text content | Anything from req.body, req.query, req.params |
Warning: Never use <%- %> with unsanitized user input. If a user submits <script>document.cookie</script> as their name and you render it with <%- %>, that script will execute in every visitor's browser.
4. <% code %> — Scriptlet (Logic, No Output)
This tag runs JavaScript code but does not output anything. It is used for control flow — if, for, forEach, variable declarations.
Express route:
app.get('/users', (req, res) => {
res.render('users', {
users: [
{ name: 'Arjun', active: true },
{ name: 'Priya', active: false },
{ name: 'Rahul', active: true }
]
});
});
views/users.ejs:
<h1>User List</h1>
<ul>
<% users.forEach(function(user) { %>
<% if (user.active) { %>
<li class="active"><%= user.name %> (Active)</li>
<% } else { %>
<li class="inactive"><%= user.name %> (Inactive)</li>
<% } %>
<% }); %>
</ul>
Rendered HTML:
<h1>User List</h1>
<ul>
<li class="active">Arjun (Active)</li>
<li class="inactive">Priya (Inactive)</li>
<li class="active">Rahul (Active)</li>
</ul>
Notice: the <% %> tags themselves produce no visible output. Only the <%= %> tags inside them generate text.
You can also declare variables in scriptlets:
<% const greeting = 'Hello'; %>
<% const fullName = user.first + ' ' + user.last; %>
<h1><%= greeting %>, <%= fullName %>!</h1>
5. <%# comment %> — EJS Comments
EJS comments are stripped from the output. They never reach the browser.
<%# This is an EJS comment — invisible in the final HTML %>
<h1>Welcome</h1>
<!-- This is an HTML comment — visible in page source -->
<p>Content here</p>
<%# TODO: Add user avatar when image upload is implemented %>
Rendered HTML:
<h1>Welcome</h1>
<!-- This is an HTML comment — visible in page source -->
<p>Content here</p>
| Comment Type | Visible in Browser Source? |
|---|---|
<%# EJS comment %> | No — completely removed |
<!-- HTML comment --> | Yes — visible in "View Source" |
Use EJS comments for development notes, TODOs, and explanations you do not want users to see.
6. <%- include('partial') %> — Including Other Templates
This tag inserts the compiled content of another EJS file at that position. It is the foundation of reusable layouts.
views/partials/header.ejs:
<header>
<nav>
<a href="/">Home</a>
<a href="/about">About</a>
<a href="/contact">Contact</a>
</nav>
</header>
views/home.ejs:
<!DOCTYPE html>
<html>
<head><title>Home</title></head>
<body>
<%- include('partials/header') %>
<main>
<h1>Welcome Home</h1>
</main>
<%- include('partials/footer') %>
</body>
</html>
Why <%- %> and not <%= %>? The included file contains HTML. If you used <%= include() %>, all the HTML tags would be escaped and rendered as visible text, not as actual elements. You need raw output here.
7. Passing Data to Views — The Full Picture
Express route:
app.get('/shop', (req, res) => {
res.render('shop', {
title: 'Our Shop',
products: [
{ name: 'Laptop', price: 999, inStock: true },
{ name: 'Keyboard', price: 79, inStock: true },
{ name: 'Monitor', price: 349, inStock: false }
],
user: req.session.user || null,
year: new Date().getFullYear()
});
});
Inside views/shop.ejs, every key is a top-level variable:
<title><%= title %></title>
<% products.forEach(function(p) { %>
<div><%= p.name %> — $<%= p.price %></div>
<% }); %>
<% if (user) { %>
<p>Welcome back, <%= user.name %></p>
<% } %>
<footer>© <%= year %></footer>
8. Accessing Variables — What is Available Inside a Template
| Variable | Source | Example |
|---|---|---|
Any key from res.render() data object | Explicit | <%= title %> |
locals | Built-in object containing all passed data | <%= locals.title %> |
| Any standard JS globals | Built-in | <%= Date.now() %>, <%= Math.random() %> |
JSON | Built-in | <%= JSON.stringify(data) %> |
You cannot access req, res, app, or require inside templates unless you explicitly pass them. This is by design — templates should not contain server logic.
9. The locals Pattern — Handling Undefined Variables
If you reference a variable that was not passed by res.render(), EJS throws a ReferenceError. The locals object provides a safe way to check:
Problem — this crashes if errorMessage was not passed:
<!-- DANGEROUS — throws ReferenceError if errorMessage is undefined -->
<% if (errorMessage) { %>
<div class="error"><%= errorMessage %></div>
<% } %>
Solution — use locals:
<!-- SAFE — locals.errorMessage is just undefined, no crash -->
<% if (locals.errorMessage) { %>
<div class="error"><%= locals.errorMessage %></div>
<% } %>
Why this works: locals is always defined — it is the object res.render() received. Accessing a non-existent property on an object returns undefined (no error). Accessing a non-existent variable in scope throws ReferenceError.
Common use case — flash messages:
// Route that may or may not set a message
app.get('/login', (req, res) => {
res.render('login'); // No errorMessage passed
});
app.post('/login', (req, res) => {
// ... authentication fails
res.render('login', { errorMessage: 'Invalid credentials' });
});
<!-- views/login.ejs — works for both routes -->
<% if (locals.errorMessage) { %>
<div class="alert alert-danger"><%= errorMessage %></div>
<% } %>
<form method="POST" action="/login">
<input type="email" name="email" placeholder="Email">
<input type="password" name="password" placeholder="Password">
<button type="submit">Log In</button>
</form>
10. Whitespace Control — -%> and _%>
EJS tags can leave extra blank lines in the output. Two special closings trim whitespace:
| Closing | Effect |
|---|---|
%> | Normal — keeps any following newline |
-%> | Trim the newline that follows the tag |
_%> | Slurp all whitespace that follows the tag |
Without trimming:
<% if (true) { %>
<p>Hello</p>
<% } %>
Output has blank lines around <p>Hello</p>.
With -%>:
<% if (true) { -%>
<p>Hello</p>
<% } -%>
Output is cleaner — no extra blank lines. This is cosmetic; it does not affect how the page renders in the browser, but it produces tidier HTML source.
11. Escaping EJS Delimiters
If you need to show literal <%= %> text in your output (for example, in a tutorial page), escape the opening tag:
<!-- Show the literal text <%= username %> on the page -->
<p>To display a variable, use: <%%= username %%></p>
Rendered:
<p>To display a variable, use: <%= username %></p>
The <%% produces a literal <% in the output.
12. Expressions You Can Use Inside Tags
Since EJS runs real JavaScript, you can use any valid JS expression:
<!-- String methods -->
<p><%= username.toUpperCase() %></p>
<!-- Ternary operator -->
<p>Status: <%= isActive ? 'Active' : 'Inactive' %></p>
<!-- Template literals -->
<p><%= `Hello, ${firstName} ${lastName}` %></p>
<!-- Math -->
<p>Total: $<%= (price * quantity).toFixed(2) %></p>
<!-- Array methods -->
<p>Count: <%= items.length %></p>
<p>Names: <%= users.map(u => u.name).join(', ') %></p>
<!-- Object access -->
<p><%= user.address.city %></p>
<!-- Logical OR for defaults -->
<p><%= username || 'Guest' %></p>
<!-- Nullish coalescing -->
<p><%= settings.theme ?? 'light' %></p>
Best practice: Keep expressions simple. If you need more than one line of logic, use a <% %> scriptlet to compute the value first, then output it:
<%
const fullAddress = [
user.address.street,
user.address.city,
user.address.state,
user.address.zip
].filter(Boolean).join(', ');
%>
<p>Address: <%= fullAddress %></p>
13. Complete Example — All Tags in One Template
Express route:
app.get('/demo', (req, res) => {
res.render('demo', {
title: 'EJS Syntax Demo',
user: { name: 'Priya', role: 'admin' },
announcements: [
'Server maintenance on Friday',
'New feature: dark mode'
],
richContent: '<em>This is italic HTML</em>'
});
});
views/demo.ejs:
<!DOCTYPE html>
<html>
<head>
<title><%= title %></title>
</head>
<body>
<%# Include the shared header partial %>
<%- include('partials/header') -%>
<%# Escaped output — safe for user-generated content %>
<h1>Welcome, <%= user.name %></h1>
<%# Scriptlet — logic without output %>
<% const isAdmin = user.role === 'admin'; %>
<%# Conditional rendering %>
<% if (isAdmin) { -%>
<span class="badge">Admin</span>
<% } -%>
<%# Loop through array %>
<ul>
<% announcements.forEach(function(item) { -%>
<li><%= item %></li>
<% }); -%>
</ul>
<%# Unescaped output — only for trusted HTML %>
<div class="rich-text">
<%- richContent %>
</div>
<%# Safe check for optional variable %>
<% if (locals.errorMessage) { -%>
<div class="error"><%= errorMessage %></div>
<% } -%>
<%- include('partials/footer') -%>
</body>
</html>
14. Key Takeaways
<%= %>is your default — it escapes HTML and prevents XSS. Use it for all user-facing data.<%- %>outputs raw HTML — only use for trusted content andinclude()calls.<% %>runs JavaScript silently — use forif,for,forEach, variable declarations.<%# %>is for comments that never reach the browser.- Use
locals.variableNameto safely check for variables that may not have been passed. - Keep template expressions simple; move complex logic into scriptlets or, better yet, into the route handler.
Explain-It Challenge
Explain without notes:
- Why is
<%= userInput %>safer than<%- userInput %>when displaying a comment submitted by a user? - What is the difference between an EJS comment (
<%# %>) and an HTML comment (<!-- -->)? - Why does
<% if (errorMessage) { %>crash but<% if (locals.errorMessage) { %>does not whenerrorMessagewas never passed?
Navigation: <- 3.5.b — Setting Up EJS | 3.5.d — Control Flow in EJS ->