The problem no static HTML file can solve
Open a live cricket score page during an IPL match and refresh it a minute later. The team names, the over count, the run rate — all of it has changed, but the layout, the fonts, the position of the scoreboard on the page have not moved an inch. Somewhere on a server, the exact same HTML skeleton is being reused for every match, every over, every refresh — only the numbers inside it change. No one is hand-editing an HTML file every time a batsman hits a boundary. Something is generating that HTML automatically, on demand, by combining a fixed layout with fresh data.
That "something" is the subject of this chapter: a template engine. By the end of it you will understand, precisely and by tracing code line by line, how a tool called EJS (Embedded JavaScript) turns a single HTML skeleton plus a JavaScript object into a finished web page — and why almost every dynamic website you use, from a school results portal to a food-delivery order tracker, is built this way instead of by writing thousands of separate HTML files.
Why "just write more HTML files" breaks down
Suppose your school wants a web page that shows each student their own report card at a URL like /report/ananya. Without a template engine, the only way to do this with plain HTML is to write a separate, complete HTML file for every single student:
<!-- report_ananya.html -->
<h1>Ananya's Report Card</h1>
<p>Grade: 9</p>
<table>
<tr><td>Mathematics</td><td>92</td></tr>
<tr><td>Science</td><td>88</td></tr>
</table>
<!-- report_rohan.html -->
<h1>Rohan's Report Card</h1>
<p>Grade: 9</p>
<table>
<tr><td>Mathematics</td><td>76</td></tr>
<tr><td>Science</td><td>81</td></tr>
</table>
For a class of 40 students, that is 40 nearly identical files. If the school decides to add a "Percentage" column, you now have to edit all 40 files by hand, correctly, without a single typo. If a new student joins mid-year, you write a 41st file from scratch. This does not scale — not to 40 students, and certainly not to the millions of PNR numbers IRCTC has to generate a status page for, or the crores of UPI transactions that each need their own receipt screen. The layout is identical every single time; only the data changes. Copy-pasting HTML to change a few words is exactly the kind of repetitive, mechanical, rule-based work a computer should be doing instead of a human.
What a template actually is
Think of a template the way you'd think of a pre-printed answer sheet with blanks on it — the header, the roll-number box, the ruled lines are printed once and reused for every student; only the blanks get filled in differently each time. A template is exactly this idea applied to HTML: a single file that contains the fixed HTML structure of a page, plus placeholders — marked spots — that get filled in with real values each time the page is generated.
A template engine is the program that does the filling. It takes two inputs — a template file and a JavaScript object holding the actual data — and produces one output: a plain, ordinary HTML string with every replaced by real content. That output is what actually gets sent to the browser. The browser never sees the template file, the placeholders, or any of the logic that filled them in; it only ever receives finished HTML, exactly as if a human had typed it by hand for that one specific request.
Meet EJS
EJS stands for Embedded JavaScript. It is a template engine that lets you write ordinary HTML and drop small pieces of real JavaScript directly inside it, marked off by special tags. When you run a template through EJS, it evaluates every piece of embedded JavaScript, drops the fixed HTML through unchanged, and stitches the two together into one output string.
EJS recognises four kinds of tags:
<%= expression %>— evaluate the JavaScript expression, convert it to text, HTML-escape it, and insert it into the output.<%- expression %>— same as above, but insert the result without escaping — used only when the value itself is trusted HTML you want rendered as markup.<% statement %>— a "scriptlet": run this JavaScript statement (anif, a loop, a variable declaration) but insert nothing into the output. This is how templates get logic — branches and repetition — without EJS needing its own separate mini-language.<%# comment %>— a comment; ignored completely, never appears in the output.
Everything else in the file — every character not inside a tag — is treated as literal text and copied to the output exactly as written, including spaces and line breaks. That last detail matters more than it sounds, and we'll come back to it precisely later in this chapter.
Worked example 1: filling in a single blank
Here is the smallest possible EJS template, rendered with Node.js:
const ejs = require('ejs');
const template = 'Hello, <%= name %>! You are in Grade <%= grade %>.';
const data = { name: 'Ananya', grade: 9 };
const html = ejs.render(template, data);
console.log(html);
Let's trace exactly what ejs.render does, character by character, because tracing — not guessing — is the only reliable way to reason about template output.
- The engine scans the template left to right. The first stretch,
Hello,, contains no tags, so it is copied straight to the output. - It hits
<%= name %>. This is an output tag, so it evaluates the JavaScript expressionnameagainst thedataobject.data.nameis'Ananya'. Since the string has no HTML-sensitive characters, escaping changes nothing, andAnanyais appended to the output. - The next stretch,
! You are in Grade, is literal text and is copied unchanged. - It hits
<%= grade %>, evaluatesgradeto the number9, converts it to the string"9", and appends it. - The final stretch,
., is copied unchanged.
Concatenating every piece in order gives the output: Hello, Ananya! You are in Grade 9. — and that is precisely what console.log(html) prints. Nothing about this process is magic; it is a left-to-right scan that alternates between "copy this text" and "evaluate this JavaScript and copy the result."
Two things beginners get wrong
Misconception 1: <%= %> and <%- %> do the same thing. They look almost identical, so it's tempting to treat them as interchangeable. They are not — one escapes, one doesn't — and the difference is the difference between showing a user's text safely and letting a user inject arbitrary markup into your page. Here is the contrast, traced precisely:
const template = '<p><%= comment %></p>';
const data = { comment: '<b>Nice job!</b>' };
console.log(ejs.render(template, data));
// Output: <p><b>Nice job!</b></p>
Because <%= %> escapes, every < in comment becomes the entity < and every > becomes >. When a browser receives <b>, it does not treat it as a bold tag at all — it decodes the entities back and displays the literal characters <b>Nice job!</b> as plain visible text on the page. Now compare the unescaping tag on the same data:
const template2 = '<p><%- comment %></p>';
console.log(ejs.render(template2, data));
// Output: <p><b>Nice job!</b></p>
Here the raw <b> tags pass straight through untouched. When the browser receives this, it is a real, live <b> element, and it genuinely renders Nice job! in bold. This is exactly why the choice matters: if comment were text typed by a random visitor rather than trusted content, and you used <%- %>, that visitor could type <script>...</script> instead of <b>, and your page would execute their script — a real web-security bug called cross-site scripting. The safe default is always <%= %>; you reach for <%- %> only when you deliberately want to insert HTML you already trust, such as markup your own server generated.
Misconception 2: the browser receives the .ejs file, tags and all. It does not. Rendering happens entirely on the server, before any response is sent. By the time the HTML reaches the browser's network tab, every <% %>, <%= %>, and <%- %> is gone — replaced by the plain text or markup it produced. If you "View Source" on a page rendered with EJS, you will never see the word ejs or a single percent sign belonging to a tag; you will see exactly the kind of static-looking HTML from the very first section of this chapter, just generated fresh for that one request instead of typed by hand.
Worked example 2: branching with a scriptlet
Scriptlet tags let a template make decisions. Suppose we know a student's total marks and want the page to print PASS or FAIL:
<% if (totalMarks >= 99) { -%>
<p>Result: <strong>PASS</strong></p>
<% } else { -%>
<p>Result: <strong>FAIL</strong></p>
<% } -%>
You'll notice the tags end with -%> instead of the plain %> we used before. That trailing hyphen is a trim marker, and it exists to solve a real problem: every scriptlet tag sits on its own line in the file, which means there is a newline character immediately after its closing %>. Without the hyphen, EJS treats that newline as literal text and copies it into the output, leaving a stray blank line behind every scriptlet in your generated HTML. The -%> tells EJS "trim the newline that immediately follows this tag" — the scriptlet line then contributes nothing at all to the output, not even whitespace, which is exactly what we want for a line whose only job is to run logic.
Since a JavaScript if / else only ever runs one branch, only one of the two <p> lines is ever copied to the output — the other is simply never reached. For totalMarks = 259, the condition 259 >= 99 is true, so tracing top to bottom: the first scriptlet line is trimmed away entirely, the PASS paragraph's literal text (including its own trailing newline, which has no trim marker on it) is copied, the else branch is skipped completely, and the final <% } -%> is trimmed away too. The exact output is a single clean line: <p>Result: <strong>PASS</strong></p>, followed by one newline.
Worked example 3: Ananya's report card — loops
Now the part that makes template engines genuinely powerful: generating a variable number of repeated rows from an array, using a loop scriptlet. Here is the data:
const student = {
name: 'Ananya',
grade: 9,
marks: [
{ subject: 'Mathematics', score: 92 },
{ subject: 'Science', score: 88 },
{ subject: 'English', score: 79 }
]
};
And here is a template that turns the marks array into table rows:
<table>
<tr><th>Subject</th><th>Marks</th></tr>
<% student.marks.forEach(function (m) { -%>
<tr><td><%= m.subject %></td><td><%= m.score %></td></tr>
<% }); -%>
</table>
Trace it exactly, the same discipline as before. The first two lines are literal text with no tags, so they are copied unchanged: <table>\n<tr><th>Subject</th><th>Marks</th></tr>\n. Next comes the scriptlet <% student.marks.forEach(function (m) { -%> — its trim marker deletes the newline after it, so this line contributes nothing to the output; all it does is open a loop in the compiled JavaScript, with everything up to the matching <% }); %> now sitting inside that loop's body.
That loop body is one line: <tr><td><%= m.subject %></td><td><%= m.score %></td></tr>\n. Because it is inside forEach, EJS executes it once per array element, with m bound to each object in turn:
m = { subject: 'Mathematics', score: 92 }→<tr><td>Mathematics</td><td>92</td></tr>m = { subject: 'Science', score: 88 }→<tr><td>Science</td><td>88</td></tr>m = { subject: 'English', score: 79 }→<tr><td>English</td><td>79</td></tr>
Finally, <% }); -%> closes the loop and, with its own trim marker, also contributes nothing, and </table> is copied unchanged. Stitching every retained piece together in order gives the real, exact output:
<table>
<tr><th>Subject</th><th>Marks</th></tr>
<tr><td>Mathematics</td><td>92</td></tr>
<tr><td>Science</td><td>88</td></tr>
<tr><td>English</td><td>79</td></tr>
</table>
It's worth being honest about what would happen if we had left the -%> trim markers off both scriptlet lines: the newline sitting right after each %> would then be treated as ordinary literal text and copied into the output too, leaving a blank line between </tr><th>...</th></tr> and the first data row, and another blank line right before </table>. A browser would still render an identical-looking table, because it collapses whitespace-only lines when laying out a page — but the raw HTML text itself would not be the clean block shown above; it would have those two extra blank lines in it. This is a genuine, easy-to-miss detail of how EJS's default whitespace handling works, and the trim marker is the tool that keeps generated HTML text as tidy as the template that produced it.
Computing the total: a short detour through reduce
Before we can print Ananya's total marks, we need to add up the score field across every object in the marks array. One clean way to do this in JavaScript is Array.prototype.reduce:
const totalMarks = student.marks.reduce((sum, s) => sum + s.score, 0);
reduce walks through the array once, carrying a running total forward from one element to the next. The 0 at the end is the starting value of that running total. The first argument, (sum, s) => sum + s.score, is a short-hand arrow function — a compact way to write a function without the function keyword — that EJS-independent JavaScript calls once per array element, handing it the running total so far as sum and the current array element as s, and returning the new running total. Concretely: start with sum = 0; after Mathematics, sum = 0 + 92 = 92; after Science, sum = 92 + 88 = 180; after English, sum = 180 + 79 = 259. reduce returns that final value, so totalMarks is 259.
The complete report card, combined and traced
Putting the pieces together — a heading, the total, the loop, and the pass/fail branch — into one template, rendered with { student, totalMarks: 259, maxMarks: 300 }:
<h1><%= student.name %>'s Report Card</h1>
<p>Grade: <%= student.grade %></p>
<table>
<tr><th>Subject</th><th>Marks</th></tr>
<% student.marks.forEach(function (m) { -%>
<tr><td><%= m.subject %></td><td><%= m.score %></td></tr>
<% }); -%>
</table>
<p>Total: <%= totalMarks %> / <%= maxMarks %></p>
<% if (totalMarks >= maxMarks * 0.33) { -%>
<p>Result: <strong>PASS</strong></p>
<% } else { -%>
<p>Result: <strong>FAIL</strong></p>
<% } -%>
Applying the exact same rules as every trace above — literal text copied unchanged, <%= %> tags replaced with their escaped values, scriptlet lines with -%> contributing nothing, the loop body repeated once per array element, and only the true branch of the if kept — gives this real, complete output:
<h1>Ananya's Report Card</h1>
<p>Grade: 9</p>
<table>
<tr><th>Subject</th><th>Marks</th></tr>
<tr><td>Mathematics</td><td>92</td></tr>
<tr><td>Science</td><td>88</td></tr>
<tr><td>English</td><td>79</td></tr>
</table>
<p>Total: 259 / 300</p>
<p>Result: <strong>PASS</strong></p>
This one template, unchanged, produces a completely different but equally correct page for Rohan, or for any of the 40 students in the class, or for a new student who joins tomorrow — because the layout lives in exactly one file, and only the data passed to ejs.render changes between calls.
Where this fits in a real web server
In an actual website, ejs.render is rarely called by hand — a web framework like Express calls it for you whenever a browser requests a page:
app.get('/report/:name', function (req, res) {
const student = findStudent(req.params.name);
const totalMarks = student.marks.reduce((sum, s) => sum + s.score, 0);
res.render('report', { student: student, totalMarks: totalMarks, maxMarks: 300 });
});
When a browser requests /report/ananya, Express calls findStudent to fetch that one student's data, computes the total, and calls res.render, which loads report.ejs from disk, runs it through the exact same EJS engine we traced above, and sends the resulting HTML string back as the HTTP response. The browser that receives it has no way to tell whether a human typed that HTML by hand a year ago or a server generated it forty milliseconds earlier — which is precisely the point. The diagram below shows this whole pipeline for a single request:
Where this sits in your CS foundations
Two ideas from this chapter are standard vocabulary in Computer Science and Informatics Practices, and worth pinning down precisely for exams as well as for building real projects: a static web page is one whose HTML file is identical for every visitor and every request — the server just hands over the same bytes each time; a dynamic web page is one whose HTML is generated freshly, per request, usually by combining a template with data pulled from somewhere — a database, a form submission, an API. EJS is one concrete tool for producing dynamic pages on a Node.js server; the same underlying idea — a fixed layout, a data object, and an engine that merges them — appears under different names in other ecosystems too (Python's Flask framework, for instance, uses a template engine called Jinja2 that works on the same principle, just with different tag syntax). Understanding EJS deeply, by tracing its output character by character the way we did in this chapter, gives you the concept in a form that transfers directly to any of those other tools.
Summary
- A template is a reusable HTML skeleton with placeholders; a template engine fills those placeholders with real data to produce one finished HTML page per request.
- EJS embeds real JavaScript inside HTML using four tags:
<%= %>(escaped output),<%- %>(raw, unescaped output),<% %>(logic, no output), and<%# %>(comment). - Rendering is a left-to-right scan: literal text is copied as-is; output tags are evaluated and inserted; scriptlet tags run logic and insert nothing themselves, but the static text between a scriptlet's opening and closing tag can be executed multiple times (loops) or skipped entirely (conditionals).
<%- %>should only ever be used on trusted content, never on raw user input — using it carelessly on user-supplied text is a real cross-site-scripting security bug, not a hypothetical one.- The trim marker
-%>deletes the newline immediately following a tag, which is why well-written EJS templates produce compact HTML with no stray blank lines; leaving it off is not wrong, just untidy. - The browser never receives the
.ejsfile or any tag — only the final, plain HTML string that rendering produced.
Practice: trace it yourself
- Given
const template = 'Roll No: <%= roll %>, Section: <%- section %>';andconst data = { roll: 14, section: '<b>A</b>' };, write out the exact output character by character. Which tag caused the<b>to survive into the output as a real tag rather than as visible text? - A template contains
<% for (let i = 0; i < items.length; i++) { %>\n<li><%= items[i] %></li>\n<% } %>— note: no trim markers this time. Foritems = ['Pen', 'Book'], write out the full raw output, including every blank line the missing trim markers leave behind, and explain in one sentence why a browser would display it identically to the trimmed version anyway. - Rewrite the loop from question 2 with
-%>trim markers added in the correct two places, and write out the new, cleaner output for the same data. - A friend writes
<%- userComment %>to display a comment typed into a form by any visitor to a site. Explain, in your own words, what could go wrong, and which tag they should have used instead. - Using the pattern from the "complete report card" example, write the EJS scriptlet and output tags (not the surrounding HTML) needed to print a student's average mark, given
totalMarksand a variablesubjectCountholding the number of subjects, rounded to the nearest whole number usingMath.round().
Think About It
Think about this: How would you explain template engines: rendering dynamic html with ejs to a friend who has never seen a computer? What real-world analogy would you use? Imagine you had to build a system using these concepts — what would be your first step? Try this: before moving on, write down three things you learned and one question you still have.