Rendering with EJS
EJS (Embedded JavaScript) lets you write HTML files sprinkled with plain JavaScript expressions, so the markup you already know becomes the template language. It is one of the most popular view engines for Express because there is almost nothing new to learn: if you can write a for loop and an if statement, you can build a dynamic page. This page shows how to wire EJS into an Express app, pass data with res.render, and use the tag set for interpolation, escaping, conditionals, and loops.
Installing and configuring EJS
EJS is a separate package. Install it alongside Express and tell the app which engine to use.
npm install express ejs
Express has built-in support for engines that follow the (path, options, callback) signature, and EJS ships one, so configuration is two lines. Set the view engine so you can omit the file extension, and optionally set views to point at your templates directory (it defaults to ./views).
const express = require("express");
const path = require("path");
const app = express();
app.set("view engine", "ejs");
app.set("views", path.join(__dirname, "views"));
app.listen(3000, () => console.log("http://localhost:3000"));
Templates live in the views/ folder with a .ejs extension — for example views/index.ejs. Because view engine is set, res.render("index") resolves to views/index.ejs automatically.
Passing data with res.render
res.render(view, locals) compiles the named template, runs it with the locals object exposed as in-scope variables, and sends the resulting HTML with a text/html content type. Every key in the locals object becomes a variable available directly inside the template.
app.get("/", async (req, res) => {
const user = await getCurrentUser(req);
res.render("index", {
title: "Dashboard",
user,
tasks: ["Write docs", "Review PR", "Ship release"],
});
});
The template can now reference title, user, and tasks without any further plumbing.
EJS tags: output and scriptlets
EJS distinguishes between tags that print a value and tags that simply run code. The two output tags differ in one critical way: escaping.
| Tag | Purpose | Output |
|---|---|---|
<%= value %> | Output, HTML-escaped | Safe interpolation |
<%- value %> | Output, unescaped (raw HTML) | Renders markup as-is |
<% code %> | Scriptlet — runs JS, prints nothing | Control flow |
<%# comment %> | Comment | Stripped from output |
<%- include('partial') %> | Include another template | Composition |
Use <%= %> for any value that comes from a user or database — it escapes <, >, &, ", and ', preventing cross-site scripting. Reach for <%- %> only when you genuinely need to emit trusted HTML.
<!-- views/index.ejs -->
<!DOCTYPE html>
<html>
<head><title><%= title %></title></head>
<body>
<h1>Welcome, <%= user.name %></h1>
<%- user.bioHtml %> <%# trusted, pre-sanitized markup %>
</body>
</html>
Warning:
<%- %>injects raw HTML with no escaping. Never pass untrusted input through it, or you open an XSS hole. When in doubt, use<%= %>.
Conditionals
Conditionals are written as ordinary JavaScript wrapped in scriptlet tags. Open the block with <% if (...) { %>, write HTML between the tags, and close with <% } %>.
<% if (user) { %>
<p>Signed in as <%= user.email %></p>
<a href="/logout">Log out</a>
<% } else { %>
<a href="/login">Log in</a>
<% } %>
The HTML between the tags is emitted only when the branch runs, so you can show, hide, or swap entire sections based on the data you passed in.
Loops
Iterating over a collection follows the same pattern — a scriptlet opens the loop and another closes it, with output tags inside the body.
<ul>
<% tasks.forEach(function (task, i) { %>
<li><%= i + 1 %>. <%= task %></li>
<% }); %>
</ul>
Given tasks: ["Write docs", "Review PR", "Ship release"], this renders:
Output:
<ul>
<li>1. Write docs</li>
<li>2. Review PR</li>
<li>3. Ship release</li>
</ul>
Any JavaScript loop works — for, for...of, or array methods like map — since EJS evaluates the code verbatim.
Reusing markup with includes
Repeated fragments such as headers and footers belong in their own files. include pulls another template into the current one, passing locals through automatically; you can also forward extra data as a second argument.
<%- include("partials/header", { title }) %>
<main>
<h2>Tasks</h2>
<% tasks.forEach((t) => { %><p><%= t %></p><% }); %>
</main>
<%- include("partials/footer") %>
Note the <%- output tag: an include returns an HTML string, so it must be emitted unescaped.
Best Practices
- Set
view enginetoejsonce at startup so route handlers stay free of file extensions and engine wiring. - Default to
<%= %>for every dynamic value; reserve<%- %>for HTML you have explicitly sanitized. - Keep templates thin — do data fetching and shaping in the route handler, then pass a clean locals object to
res.render. - Factor shared chrome (header, footer, nav) into
partials/and pull them in withincludeto avoid duplication. - In production, leave EJS view caching on (it is enabled automatically when
NODE_ENV=production) so templates compile once. - Always provide every variable a template reads; a missing local throws a
ReferenceErrorat render time rather than rendering blank.