Views: EJS Templates

Website Rebuild with Express

Chapter 4 · Views: EJS Templates

EJS's own name — Embedded JavaScript — directly mirrors ERB's: Embedded Ruby. That's not a coincidence, and the base syntax really is the same shape. Where the two genuinely diverge is worth getting precisely right.

The Base Tags: A Real Parallel

<!-- <% %> executes, no output --> <% if (page.featured) { %> <span>★ Featured</span> <% } %> <!-- <%= %> outputs, HTML-escaped --> <h1><%= page.title %></h1>

<% %> for control flow, <%= %> for escaped output — the identical base shape ERB already established, reused here under a deliberately parallel name.

The Raw-Output Tag: A Genuine, Precise Difference

Same idea, different symbol — worth stating precisely, not assumed
Rails' own <%== %> — added by ActionView on top of plain ERB — outputs unescaped HTML. EJS's own equivalent is <%- %>, a hyphen rather than a doubled equals sign. Both frameworks independently added their own raw-output escape hatch on top of the same "Embedded X" base idea, but each picked a genuinely different symbol to represent it. The parallel between EJS and ERB is real and worth naming — but it's not a claim that every tag is identical, and this is exactly the detail where that would be wrong.
<!-- page.body is trusted, already-formatted HTML --> <%- page.body %>

No Built-In Layout System — Composed via include()

<!-- views/partials/header.ejs --> <!DOCTYPE html> <html lang="en"> <head><title><%= page.title %></title></head> <body>
<!-- views/page.ejs --> <%- include('partials/header') %> <nav class="breadcrumb"> <% breadcrumb.forEach(crumb => { %> <a href="/<%= crumb.full_path %>"><%= crumb.title %></a> <% }); %> </nav> <h1><%= page.title %></h1> <%- page.body %> <%- include('partials/footer') %>

Astro at least had first-class <slot /> composition. EJS has no layout concept of its own at all — include() is a plain function that inlines another template's own output, and building a shared page shell out of a header/footer pair is a convention the developer invents, not a feature EJS provides.

The Breadcrumb — the Sixth and Final Planted N+1

// lib/pages.js async function getBreadcrumb(pool, pageId) { const crumbs = []; let currentId = pageId; while (currentId) { const [rows] = await pool.query('SELECT * FROM pages WHERE id = ?', [currentId]); if (rows.length === 0) break; crumbs.unshift(rows[0]); currentId = rows[0].parent_id; } return crumbs; }
The last time this series plants this pattern deliberately
One query per ancestor level — the identical pattern every sibling course built into its own breadcrumb code on purpose, planted here a sixth and final time, to be caught and fixed in Chapter 6.

Raw Output, Compared Across All Six Frameworks

FrameworkMechanism
Django{{ page.body|safe }}
Laravel{!! $page->body !!}
Rails<%== @page.body %>
Astro<div set:html={page.body} />
Express (EJS)<%- page.body %>

Hands-On Exercises

Exercise 1

Build header.ejs, footer.ejs, and page.ejs, and confirm the layout composes correctly via include().

📄 View solution
Exercise 2

Build getBreadcrumb() and confirm it produces the correct, correctly-ordered ancestor chain for a page stored at least three levels deep.

📄 View solution
Exercise 3

Store a page body containing a real <strong> tag, and confirm <%= %> escapes it into visible text while <%- %> renders it as real HTML.

📄 View solution

Chapter 4 Quick Reference

  • <% %> / <%= %> — the same base shape as Rails' own ERB, under a deliberately parallel name
  • <%- %> — EJS's own raw-output tag, a genuinely different symbol from Rails' <%== %>
  • include() — plain template inlining; no first-class layout system the way Astro's <slot /> is
  • getBreadcrumb — the sixth and final deliberately planted N+1 pattern in the series
  • Next chapter: Styling — Dark Theme