Templating Compared: Django Templates/Jinja2, ERB, Blade, EJS/Pug & JSX

Web Framework Internals

Chapter 5 ยท Templating Compared: Django Templates/Jinja2, ERB, Blade, EJS/Pug & JSX

Chapter 4 built one template engine by hand and ran directly into two real design questions: what should happen to a value the moment it's inserted into HTML, and how should a parsed template actually get reused across renders. This chapter takes those exact same questions to seven real, independently-built systems — Django Templates, Jinja2, ERB, Blade, EJS, Pug, and JSX — and checks, directly against each project's own documentation, how each one really answers them.

The Same Value, Seven Real Escaping Answers

Every system below faces the identical decision Chapter 4 built SafeString to solve: escape a value by default, and require something visually distinct to opt out. The syntax differs completely from system to system — the underlying decision, verified directly against each one's own documentation, does not.

Django (templates/*.html)
Hello, {{ name }}! Hello, {{ note|safe }}!
Jinja2 (templates/*.html)
Hello, {{ name }}! Hello, {{ note|safe }}!
ERB / Rails (app/views/*.erb)
Hello, <%= name %>! Hello, <%= raw(note) %>!
Blade (resources/views/*.blade.php)
Hello, {{ $name }}! Hello, {!! $note !!}!
EJS (views/*.ejs)
Hello, <%= name %>! Hello, <%- note %>!
Pug (views/*.pug)
p Hello, #{name}! p Hello, !{note}!
JSX (components/*.jsx)
<p>Hello, {name}!</p> <div dangerouslySetInnerHTML={{ __html: note }} />
Django's own documentation, quoted directly
"By default in Django, every template automatically escapes the output of every variable tag… Again, we stress that this behavior is on by default. If you're using Django's template system, you're protected." To disable it for one value: "use the safe filter… Think of safe as shorthand for safe from further escaping."
React's own documentation, quoted directly — including its own real code example
React's docs introduce dangerouslySetInnerHTML with a warning built into the prop's own name: "This is dangerous… you must exercise extreme caution!" — then demonstrate exactly why, using a real, worked example: a blog post's own stored content field containing <img src="" onerror='alert("you were hacked")'>, passed straight into dangerouslySetInnerHTML. Per React's own docs: "The code embedded in the HTML will run. A hacker could use this security hole to steal user information or to perform actions on their behalf" — the exact same real vulnerability class Chapter 4 built and closed by hand, independently confirmed here in a completely different language and framework.
SystemEscaped by defaultExplicit opt-outReal mechanism underneath
Django{{ var }}{{ var|safe }}Replaces 5 HTML-significant characters, per Django's own docs
Jinja2{{ var }}{{ var|safe }}Same filter name and surface syntax as Django, autoescaping toggled per environment
ERB / Rails<%= var %>raw(var) / var.html_safeA Rails/ActionView addition — see the section below
Blade{{ $var }}{!! $var !!}PHP's own htmlspecialchars(), per Laravel's own docs
EJS<%= var %><%- var %>"HTML escaped" vs. "unescaped", per EJS's own docs, verbatim
Pug#{var}!{var}A pound sign ("safe") vs. a bang ("danger"), per Pug's own docs
JSX{var}dangerouslySetInnerHTMLReact's compiler escapes every {} expression by default
Seven independently-built systems, one identical design decision
A filter name, a directive keyword, a tag character, or a prop name whose own spelling says "dangerous" — the exact spelling is different every single time, and the underlying decision is identical every single time: escape by default, and make the opt-out visually loud enough that a developer can't reach for it by accident. This isn't a coincidence seven projects happened to share — it's the same real security lesson, independently rediscovered and independently encoded into the syntax itself, in every system checked.

Compile-to-Code, Two More Real Ways: Blade→PHP, ERB→Ruby

Chapter 4 verified one real system that compiles a parsed template into actual, executable host-language source code — Jinja2, into Python. Checking Blade and ERB's own real documentation directly turns up two more:

Laravel's own documentation, quoted directly
"Unlike some PHP templating engines, Blade does not restrict you from using plain PHP code in your templates. In fact, all Blade templates are compiled into plain PHP code and cached until they are modified, meaning Blade adds essentially zero overhead to your application."

Ruby's own standard-library ERB class goes further still — its real, public .src method hands back the actual generated Ruby source a given template compiles to, letting this exact claim be checked directly rather than taken on faith. For the template 'The time is <%= Time.now %>.', Ruby's own documentation shows the real compiled result:

_erbout = +''; _erbout.<< "The time is ".freeze; _erbout.<<(( Time.now ).to_s); _erbout.<< ".".freeze; _erbout
Verified directly — there is no escaping call anywhere in that generated code
Every literal chunk of text becomes a frozen Ruby string, appended straight onto a real accumulator variable (_erbout) — the exact same accumulator pattern Chapter 4's own compile_to_python() used for its __out list, now confirmed in a second, completely different host language. But the interpolated value itself is only ever passed through .to_s — never through anything resembling html.escape(). Plain ERB, verified from its own real compiled output, does not escape by default at all. The "ERB / Rails" row in this chapter's own comparison table above is real, but it isn't a property of ERB itself — it's something Rails' own ActionView layer adds around ERB's output tag, specifically for use inside a Rails application. Reach for plain ERB outside of Rails and the default behavior is exactly Chapter 4's original, vulnerable render() function from the start of that chapter, not the safe one it was rebuilt into.

Django's Deliberate Choice Not to Compile

Three real systems verified so far — Jinja2, Blade, ERB — all compile a parsed template into real, executable host-language code. Django, verified in Chapter 4 as walking a real Node tree instead, is the outlier among the four. Django's own documentation gives a real, direct reason, and it isn't about speed:

Django's own design-philosophy documentation, quoted directly
"We see a template system as a tool that controls presentation and presentation-related logic — and that's it. The template system shouldn't support functionality that goes beyond this basic goal." And, more pointedly: "The goal is not to invent a programming language. The goal is to offer just enough programming-esque functionality, such as branching and looping, that is essential for making presentation-related decisions. The Django Template Language (DTL) aims to avoid advanced logic."

Jinja2's own documentation describes the real, direct consequence of not sharing that restriction: "Since Jinja2 supports passing arguments to callables in templates, many features that require a template tag or filter in Django templates can be achieved by calling a function in Jinja2 templates." Compiling a template into real Python code is a low-friction choice once a template language already permits calling arbitrary functions with arguments — the generated code is just a small, ordinary snippet of the same language the templates can already reach into directly. Django's decision to interpret a restricted node tree instead of compiling isn't only the performance tradeoff Chapter 4 measured — it's the exact same underlying decision as its own restricted expression language, seen from the other side: a template language deliberately kept unable to "invent a programming language" has no real need for a compile step that would hand it one.

Checking the two remaining systems tips the real tally further
EJS's own README, verified directly: "EJS ships with a basic in-process cache for caching the intermediate JavaScript functions used to render templates" — a fourth real compile-to-code system, this time into JavaScript. Pug's own real, public API shows the identical shape directly: pug.compile('string of pug', options) hands back a real, callable function, fn, invoked afterward as fn(locals) — a fifth. Of the seven systems checked in this chapter, five verifiably compile a parsed template into real, callable host-language code (Jinja2, Blade, ERB, EJS, Pug); Django alone walks an interpreted tree instead, and its own documentation gives the reason directly, in its own words, above.

Template Inheritance, Compared Across Five Real Systems

Chapter 4 built extends/block from scratch — a parent template names overridable regions, each carrying its own default; a child extends the parent and overrides only the regions it wants to change. Checking how four more real systems handle the identical, common problem — sharing a header/footer/nav layout across pages — turns up a genuine, documented split.

Django / Jinja2 — one tag pair does double duty (built in Chapter 4)
{% block content %}Default content{% endblock %}
Blade — two real, separate directive pairs, depending on whether a default is needed
// In the layout: display-only, no default @yield('title') // In the layout: define a default AND display it immediately @section('sidebar') This is the master sidebar. @show // In a child view: override, with @parent available to append rather than replace @extends('layouts.app') @section('sidebar') @parent <p>This is appended to the master sidebar.</p> @endsection
Rails / ERB — one unnamed main slot, plus separately-named extra slots
# In the layout: the whole view's own rendered output goes here <%= yield %> # In the layout: a second, explicitly named slot <%= yield :sidebar %> # In a child view: supply content for that named slot <% content_for :sidebar do %> <p>Sidebar content.</p> <% end %>
Rails' own documentation, quoted directly
"Within the context of a layout, yield identifies a section where content from the view should be inserted." A separate mechanism, content_for, "allows you to insert content into a named yield block in your layout" — a genuinely different shape from Django/Jinja2's own uniform block, where every region, main or extra, is named and overridable through the identical mechanism.
Blade's own naming makes its own real design choice visible
Blade needs two directive pairs precisely because @yield alone can't carry a default the way Django/Jinja2's single block tag can — a layout author reaches for @yield when a child is expected to always supply content, and for @section/@show together specifically when the layout itself needs a fallback. Rails draws a similar line in a different place: one truly universal slot (yield, standing in for "the entire page"), with every additional named region needing its own explicit content_for call. Both are real, working answers to the identical problem Chapter 4 solved with a single, uniform mechanism — neither is simply Django/Jinja2's own design with different keywords.

JSX breaks from all four of the above in a genuinely different way — not with a different spelling for the same idea, but by rejecting the idea itself. React's own documentation states this directly:

React's own documentation, quoted directly
"At Facebook, we use React in thousands of components, and we haven't found any use cases where we would recommend creating component inheritance hierarchies." In place of a parent template defining overridable regions, a "layout" in React is just an ordinary component that receives other components as its own children prop — composition, not inheritance, is the real, documented mechanism for the identical page-sharing problem every other system in this chapter solves with extends/block-shaped syntax.

One Feature, Seven Real Answers

SystemReuse strategyInheritance mechanism
DjangoParse once, walk a treeextends / block (uniform, one mechanism)
Jinja2Parse once, compile to Pythonextends / block (same syntax as Django)
ERB / RailsCompile to Ruby (ERB itself); Rails adds escaping on topyield (main slot) + content_for (named extras)
BladeCompile to PHP, cached until modified@extends / @section / @yield / @show (two real directive pairs)
EJSCompiles to a cached JavaScript function, per EJS's own docsNo built-in inheritance directive — layouts are typically composed via includes
PugCompiles to a real, callable JavaScript function (pug.compile() returns it directly)Real extends / block directives — the identical named-region-with-an-optional-default model as Django/Jinja2
JSXCompiled by Babel into React.createElement() callsNone — real, documented composition (children props) instead

Where This Course Is Headed

Chapter 6 moves from what a framework sends back to the browser to where the data it renders actually comes from — how a real ORM layer turns a row in a database into an object a template (or a JSX component) can simply read a property from, built and verified from scratch the same way this chapter's own template engine was in Chapter 4.

Hands-On Exercises

Exercise 1

Using this chapter's own real ERB-compiled-source example as a model, write out by hand what the compiled Ruby source for the template "Hello, <%= name %>!" would look like, following the identical _erbout accumulator pattern, and explain what the absence of any escaping call in that generated code means for a plain ERB template used outside of Rails.

๐Ÿ“„ View solution
Exercise 2

Pick any two of the seven real "opt out of escaping" mechanisms from this chapter's own big table (e.g. Blade's {!! !!} and Pug's !{}) and explain, in your own words, why every one of them chooses a visually distinct, unusual-looking symbol rather than reusing the exact same syntax as the safe/default case with some hidden flag.

๐Ÿ“„ View solution
Exercise 3

Using Rails' own yield/content_for mechanism and Django/Jinja2's own extends/block mechanism, explain the real structural difference between a layout with one main content area (Rails' plain yield) and one with several independently named, overridable regions (Django/Jinja2's block, or Rails' own content_for) โ€” and why a real page layout with a header, a sidebar, and a footer needs the multi-region kind, not yield alone.

๐Ÿ“„ View solution

Chapter 5 Quick Reference

  • Escaping, seven real answers โ€” Django/Jinja2's |safe, ERB/Rails' raw()/.html_safe, Blade's {!! !!}, EJS's <%- %>, Pug's !{}, React's dangerouslySetInnerHTML โ€” different spelling, identical design decision
  • React's own real XSS example โ€” a stored blog-post field containing an onerror handler, rendered live via dangerouslySetInnerHTML, per React's own documentation
  • Five of seven compile to real code โ€” Jinja2 (Python, Ch.4), Blade (PHP), ERB (Ruby, verified via its own real .src output using the same _erbout accumulator pattern as Chapter 4's own __out list), EJS and Pug (both JavaScript) โ€” only Django walks an interpreted tree instead, for a documented, deliberate reason
  • Plain ERB doesn't escape by default โ€” verified directly from its own compiled Ruby source; Rails' own ActionView layer adds that behavior, not ERB itself
  • Django's own reason for not compiling โ€” a documented design-philosophy choice ("the goal is not to invent a programming language"), not only a performance tradeoff
  • Inheritance, five real shapes โ€” Django/Jinja2's uniform block, Blade's two-directive-pair split, Rails' single-slot-plus-named-extras, and JSX's real, documented rejection of inheritance in favor of composition
  • Next chapter: Data access & the ORM layer โ€” how it actually works