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

TagNamePurposeOutput in HTML?
<%= expr %>Escaped outputPrints value with HTML entities escapedYes (safe)
<%- expr %>Unescaped outputPrints raw HTML — no escapingYes (dangerous with user input)
<% code %>ScriptletRuns JS code — no outputNo
<%# text %>CommentEJS comment — stripped from outputNo
<%- include('path') %>IncludeInserts another EJS file inlineYes (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: &lt;script&gt;alert(&quot;hacked!&quot;)&lt;/script&gt;</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:

CharacterEscaped To
&&amp;
<&lt;
>&gt;
"&quot;
'&#39;

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 UsesDangerous Uses
Including partials: <%- include('header') %>User-submitted comments
Rendering trusted HTML from a CMS admin panelSearch query display
Pre-sanitized rich text contentAnything 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 TypeVisible 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>&copy; <%= year %></footer>

8. Accessing Variables — What is Available Inside a Template

VariableSourceExample
Any key from res.render() data objectExplicit<%= title %>
localsBuilt-in object containing all passed data<%= locals.title %>
Any standard JS globalsBuilt-in<%= Date.now() %>, <%= Math.random() %>
JSONBuilt-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:

ClosingEffect
%>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

  1. <%= %> is your default — it escapes HTML and prevents XSS. Use it for all user-facing data.
  2. <%- %> outputs raw HTML — only use for trusted content and include() calls.
  3. <% %> runs JavaScript silently — use for if, for, forEach, variable declarations.
  4. <%# %> is for comments that never reach the browser.
  5. Use locals.variableName to safely check for variables that may not have been passed.
  6. Keep template expressions simple; move complex logic into scriptlets or, better yet, into the route handler.

Explain-It Challenge

Explain without notes:

  1. Why is <%= userInput %> safer than <%- userInput %> when displaying a comment submitted by a user?
  2. What is the difference between an EJS comment (<%# %>) and an HTML comment (<!-- -->)?
  3. Why does <% if (errorMessage) { %> crash but <% if (locals.errorMessage) { %> does not when errorMessage was never passed?

Navigation: <- 3.5.b — Setting Up EJS | 3.5.d — Control Flow in EJS ->