EJS 6.0.1 689,492 (Apache-2.0) is the smallest step from HTML: a template is an HTML file with JavaScript in tags. <%= %> interpolates and escapes, <%- %> interpolates raw, <% %> runs a statement without output, and include pulls in a partial relative to the current file.
<%- include('partials/head', { title }) %>
<h1><%= title %></h1>
<p>Showing <%= books.length %> books for <%= user.name %>.</p>
<ul>
<% for (const b of books) { %>
<li><%= b.title %> — <%= b.year %><% if (b.tag) { %> <em><%= b.tag %></em><% } %></li>
<% } %>
</ul>
<p><%= raw %></p>
<p><%- raw %></p>Rendered with { title: 'Bookshelf', books: [...], user: { name: 'Ada' }, raw: '<b>bold</b>' }, the server returned the page below: the escaped paragraph shows the markup as text, the raw one applies it.
<!doctype html><html lang="en">
<head><meta charset="utf-8"><title>Bookshelf</title></head><body>
<h1>Bookshelf</h1>
<p>Showing 2 books for Ada.</p>
<ul><li>Dune — 1965 <em>classic</em></li><li>Neuromancer — 1984</li></ul>
<p><b>bold</b></p>
<p><b>bold</b></p>
</body></html>
That difference is the whole security story of server-rendered HTML. <%= %> escapes &, <, >, " and ', so user text can never open a tag; <%- %> trusts the string completely and belongs only on markup you generated or sanitized yourself (Escaping Output).
EJS has no layout syntax; the usual pattern is two includes, a head and a foot, as partials/head.ejs shows. Variables passed as include's second argument are visible only inside the partial, and a trailing -%> trims the newline after a tag. Loops and conditionals are plain JavaScript — EJS's selling point and its risk: nothing stops you from putting a database call in a template. Keep templates dumb, computing totals, dates and permission flags in the route handler; templates that only interpolate are easy to test and easy to port.