Template Engines & Views
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Express 5.3 (npm express 5.3.0, published 2026-10-09) on Node.js 18 or later, the minimum
for Express 5, and it states where Express 4 (latest 4.22.3) behaves differently. Everything was checked against the
official documentation on 2026-10-29. Server-side rendering is how an Express application turns data into HTML: a route
handler calls res.render() with the name of a template and an object of values, a template engine merges the two, and
Express sends the result. The examples belong to the Bookshelf application that grows across this section, use ESM and
the node: prefix for built-in modules, and contain placeholders only, never real secrets. Templates that return JSON for
a single-page client instead are covered in Building REST APIs.
How rendering works
Express itself ships no template language. It ships a small, engine-neutral rendering pipeline, and every engine plugs into it through one function signature. The pieces are:
-
the
viewssetting, the directory (or array of directories) searched for templates; -
the
view enginesetting, the default file extension, so thatres.render('index')findsindex.ejsorindex.pug; -
app.engine(ext, callback), which registers the function that renders files with a given extension; -
res.render(view, locals, callback)andapp.render(view, locals, callback), which look the template up, merge the data and call the engine; -
app.localsandres.locals, the two places where data shared by many templates lives; -
the
view cachesetting, which keeps the resolved view object in memory.
The path of a normal call is short. res.render('books/list', { books }) asks the application to render the view and
then, unless a callback was given, sends the produced string with res.send() as text/html. If the engine reports an
error, Express calls next(err) so that the normal error-handling chain takes over, which is described in
Error Handling. The application first builds a View object for the name: it
adds the default extension if the name has none, searches every directory of the views setting in order, and also tries
name/index.ext when name is a directory. A name that resolves to no file produces an error whose message lists the
directories that were searched, which is the first thing to read when a template "is not found".
The settings that control views
| Setting | Meaning | Default and notes |
|---|---|---|
|
A directory, or an array of directories searched in order for templates. |
|
|
The engine extension used when a view name has none, without the leading dot. |
Not set. Without it, |
|
Caches the resolved |
Enabled when |
|
The class used to represent a template. Advanced: replaces the default file-based lookup. |
The built-in |
Set them with app.set() and read them with app.get(), as with every other setting listed in
Application & Settings. Because a view cache that is off in
development and on in production is the single biggest cause of "the template change did not show up in production
but did locally", the cache behavior is worth testing with NODE_ENV=production before a release.
res.render() and app.render()
res.render(view [, locals] [, callback]) is the method route handlers use. Its arguments are:
-
view, a string with the template path (relative toviews, or absolute) with or without the extension; -
locals, an optional object whose properties become variables in the template; -
callback, an optional function(err, html). When it is given, Express does not send anything and does not callnext(err): the code that passed the callback owns both the error and the response.
app.render(view [, locals] [, callback]) does the same job without a response. It is how an application renders HTML
for an email, a PDF generator or a cached fragment, and res.render() is implemented on top of it. app.render() takes
the callback as a required last argument in practice, because it has nowhere else to put the result.
Always asynchronous in Express 5
In Express 4, whether the callback of res.render() and app.render() ran synchronously or asynchronously depended on
the engine and on the view cache: a cached view rendered by a synchronous engine could call the callback before
res.render() returned. Code written against that behavior, such as setting a header after the call and relying on the
response not having been sent yet, worked by accident. Express 5 guarantees that the rendering is always asynchronous
for every engine, so the callback is never invoked in the same tick. Two consequences follow:
-
code that ran after
res.render()and assumed the template had already been rendered must move into the callback or after anawaiton a promise wrapper; -
a test that mocked an engine with a synchronous callback now sees the response on the next tick, which matters when an assertion runs immediately after the call.
res.render() still returns nothing useful, and it still does not return a promise. To use await, wrap the callback form
(or app.render()) yourself, as the first example does.
Passing data: locals, res.locals and app.locals
Three objects feed a template, and Express merges them in a fixed order:
-
app.locals, properties that live as long as the application. Express fills insettingswith the application’s settings; authors add constants such as the site name, the build number or helper functions. -
res.locals, properties scoped to one request and one response. Middleware sets them (the logged-in user, the CSRF token, the flash messages, the current URL), so every template rendered for that request sees them. -
The
localsargument ofres.render(), the most specific, for the values one route computed.
When the same name appears in more than one object, the more specific one wins: the argument beats res.locals, which
beats app.locals. All three are copied into a fresh object per render, so a template cannot change them for the next
request. This is the mechanism behind the view-locals example of the Express repository, described in
Middleware Fundamentals. Two rules keep it safe:
-
never put the whole
req.bodyorreq.queryobject intolocalsor into the render options. Engines read a few special properties from the same object (see Output escaping and cross-site scripting), and a user must not be able to choose them; -
do not store per-user data in
app.locals; it is shared by every request.
A first complete example with EJS
The following program renders the Bookshelf catalog with EJS 3 and shows the whole pipeline: views set from the module
location, view engine, an application-level constant, per-request locals from a middleware, a route that renders, a
promise wrapper around app.render(), and an error-handling middleware that renders an error page.
// server.mjs
import { fileURLToPath } from 'node:url';
import path from 'node:path';
import { promisify } from 'node:util';
import express from 'express';
const here = path.dirname(fileURLToPath(import.meta.url));
const app = express();
app.set('views', path.join(here, 'views'));
app.set('view engine', 'ejs');
app.locals.siteName = 'Bookshelf';
const books = [
{ id: 1, title: 'Dune', author: 'Frank Herbert', note: '<b>classic</b>' },
{ id: 2, title: 'Hyperion', author: 'Dan Simmons', note: '' },
];
// per-request locals visible to every template
app.use((req, res, next) => {
res.locals.currentPath = req.path;
res.locals.user = null; // a real application sets this from the session
next();
});
app.get('/', (req, res) => {
res.render('books/list', { title: 'All books', books });
});
// app.render has no response: useful for e-mail bodies, exports and tests
const render = promisify(app.render.bind(app));
app.get('/preview/:id', async (req, res) => {
const book = books.find((b) => b.id === Number(req.params.id));
if (!book) {
return res.status(404).render('error', { title: 'Not found', message: 'No such book' });
}
const html = await render('books/card', { book });
res.type('html').send(html);
});
// error-handling middleware: four arguments
app.use((err, req, res, next) => {
if (res.headersSent) {
return next(err);
}
res.status(err.status ?? 500).render('error', {
title: 'Something went wrong',
message: process.env.NODE_ENV === 'production' ? 'Unexpected error' : err.message,
});
});
app.listen(3000, () => console.log('http://localhost:3000'));
The templates, with the layout expressed as two partials that every page includes, are:
<!-- views/partials/header.ejs -->
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title><%= title %> - <%= siteName %></title>
</head>
<body>
<nav>
<a href="/" class="<%= currentPath === '/' ? 'active' : '' %>">Books</a>
<% if (user) { %><span>Signed in as <%= user.name %></span><% } %>
</nav>
<!-- views/partials/footer.ejs -->
<footer><small><%= siteName %></small></footer>
</body>
</html>
<!-- views/books/card.ejs -->
<article>
<h2><%= book.title %></h2>
<p>by <%= book.author %></p>
<% if (book.note) { %><p><%= book.note %></p><% } %>
</article>
<!-- views/books/list.ejs -->
<%- include('../partials/header') %>
<main>
<h1><%= title %></h1>
<% if (books.length === 0) { %>
<p>The shelf is empty.</p>
<% } else { %>
<ul>
<% for (const book of books) { %>
<li><%- include('card', { book }) %></li>
<% } %>
</ul>
<% } %>
</main>
<%- include('../partials/footer') %>
<!-- views/error.ejs -->
<%- include('partials/header') %>
<main>
<h1><%= title %></h1>
<p><%= message %></p>
</main>
<%- include('partials/footer') %>
The escaped output tag in card.ejs prints the note of the first book as visible text, tags included, because it
converts the angle brackets to entities; the raw tag is used only for include(), whose result is markup that is
already safe. Source: Using template engines with Express,
Express API: res.render() and
EJS.
Choosing an engine: Pug, EJS, Handlebars and Nunjucks
Any package that exports an Express-compatible renderFile or __express function, or that can be adapted with
app.engine(), works. Four engines cover most real projects and most of the older tutorials, and they sit at different
points between "HTML with holes" and "a language of its own".
| Engine | Style | Escaping | Layouts and partials | Typical reasons to choose it |
|---|---|---|---|---|
EJS 3 |
HTML with embedded JavaScript between delimiter pairs; the full language is available inside the templates. |
Escaped output tag by default; a separate raw tag. |
|
Smallest learning curve for developers who know HTML and JavaScript; the Express generator offered it. |
Pug (formerly Jade) |
Indentation-based, no closing tags, its own mixin and control syntax. |
Interpolation |
|
Dense markup, strong layout inheritance, compile-time checks. Whitespace-sensitive. |
Handlebars ( |
Logic-less Mustache-style templates with a small set of helpers. |
Double braces escape; triple braces are raw. |
Layouts directory and partials directory handled by |
Templates shared with a front end that uses Handlebars; designers who must not write code in templates. |
Nunjucks |
Jinja2-inspired syntax with filters, macros, |
|
Full inheritance, macros and imports. |
Developers coming from Django or Jinja; richer features than Handlebars with less code in the templates than EJS. |
How to register each one is shown next. The common rule is that an engine whose package exports __express needs no
call to app.engine() when its file extension matches the package name: Express calls require('ejs') for .ejs and
require('pug') for .pug on its own.
// engines.mjs - one registration per engine; pick the one you use
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import express from 'express';
const here = path.dirname(fileURLToPath(import.meta.url));
const app = express();
app.set('views', path.join(here, 'views'));
// 1. EJS: install `ejs`, nothing else to register
app.set('view engine', 'ejs');
// 2. Pug: install `pug`, nothing else to register
// app.set('view engine', 'pug');
// 3. Handlebars: the engine function comes from express-handlebars
// const { engine } = await import('express-handlebars');
// app.engine('hbs', engine({
// extname: '.hbs',
// layoutsDir: path.join(here, 'views/layouts'),
// partialsDir: path.join(here, 'views/partials'),
// defaultLayout: 'main',
// }));
// app.set('view engine', 'hbs');
// 4. Nunjucks: configure() can attach itself to the app
// const { default: nunjucks } = await import('nunjucks');
// nunjucks.configure(path.join(here, 'views'), { autoescape: true, express: app, noCache: process.env.NODE_ENV !== 'production' });
// app.set('view engine', 'njk');
The Pug, Handlebars and Nunjucks forms above are commented out, and the Handlebars and Nunjucks packages are loaded
with await import() inside their own blocks, so the file runs with only the chosen engine installed; remove the
comment marks for the one you use and delete the others. The same Bookshelf list page in each language looks
like this.
// views/books/list.pug - extends gives layout inheritance
extends ../layout
block content
h1= title
if books.length === 0
p The shelf is empty.
else
ul
each book in books
li
h2= book.title
p by #{book.author}
if book.note
p= book.note
{{!-- views/books/list.hbs - rendered inside views/layouts/main.hbs through {{{body}}} --}}
<h1>{{title}}</h1>
{{#if books.length}}
<ul>
{{#each books}}
<li>{{> bookCard this}}</li>
{{/each}}
</ul>
{{else}}
<p>The shelf is empty.</p>
{{/if}}
{# views/books/list.njk - extends and block, like Pug but in HTML syntax #}
{% extends "layout.njk" %}
{% block content %}
<h1>{{ title }}</h1>
{% for book in books %}
<article><h2>{{ book.title }}</h2><p>by {{ book.author }}</p></article>
{% else %}
<p>The shelf is empty.</p>
{% endfor %}
{% endblock %}
Source: Pug, express-handlebars, Handlebars, Nunjucks and Using template engines with Express.
Practical differences worth knowing
-
Several engines in one application. Each extension maps to one engine.
app.engine('hbs', ...)and the built-inejscan coexist; the extension in the view name (res.render('page.hbs')) selects the engine, andview engineis only the default for names without an extension. -
ESM-only engines. The default lookup uses
requireto load the engine and read its__expressexport. A package that has become ESM-only, or that exports nothing named__express, cannot be found that way and must be registered explicitly withapp.engine(), using an adapter function like the one in Developing a template engine withapp.engine(). -
Layouts. Pug and Nunjucks have inheritance (
extends,block); Handlebars gets layouts fromexpress-handlebars; EJS hasinclude()only, plus community packages. Plain partials are enough for a small application, while inheritance pays off when many pages share structure that differs in a few blocks. -
Partials. Partials in the Express sense are not a feature of Express. They are the engine’s include, partial or macro facility, and they receive only the data the template passes them explicitly (EJS and Nunjucks
includeinherit the current variables, Handlebars partials receive their context). -
Compile cost. Compiling a template is much more expensive than running a compiled one. Production must run with the engine’s own cache on, which in most engines follows
NODE_ENV=productionwhenview cacheis also on. -
Client-side reuse. Pug and Handlebars templates can be precompiled to JavaScript functions for the browser; EJS and Nunjucks can as well. Do this only when the same markup is really rendered on both sides; otherwise it doubles the surface that must be escaped.
One adapter for many engines: @ladjs/consolidate
consolidate.js was the library older tutorials used to give Express a uniform way to call dozens of engines
(Dust, Haml, Liquid, Mustache, Swig, Twig, and many more). The original package is unmaintained; the active fork is
@ladjs/consolidate, published by the Lad project. It exposes one adapter per engine, each with the callback signature
that app.engine() expects, so an unusual engine can be plugged in with one line while the engine package itself is
installed separately.
// consolidate.mjs
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import express from 'express';
import consolidate from '@ladjs/consolidate';
const here = path.dirname(fileURLToPath(import.meta.url));
const app = express();
// the engine package (here: liquid-wrapper style packages such as "liquid" or "mustache") must be installed too
app.engine('mustache', consolidate.mustache);
app.set('view engine', 'mustache');
app.set('views', path.join(here, 'views'));
app.get('/', (req, res) => {
res.render('index', { title: 'Bookshelf', books: [{ title: 'Dune' }] });
});
app.listen(3000);
Source: ladjs/consolidate and Using template engines with Express.
Use it when an engine has no Express integration of its own. For Pug, EJS, Handlebars and Nunjucks it is unnecessary
layering: the engines either work directly with Express or have a purpose-built integration with a better feature set
(layouts, helpers, partial directories) than a generic adapter can offer. The adapter hides engine options behind a
common signature, so engine-specific features have to be passed through the options object.
Developing a template engine with app.engine()
Writing an engine for Express means writing one function. app.engine(ext, callback) registers callback for every view
whose extension is ext, and Express calls it as callback(filePath, options, done):
-
filePathis the absolute path of the template file that was found; -
optionsis the merged data:app.locals, thenres.locals, then thelocalsargument, plussettings(the application’s settings) and, when the view cache is on,cache: true; -
doneis a Node-style callback,done(err, rendered). Call it with an error or with the rendered string, never both.
The function must call done exactly once, and in Express 5 Express itself guarantees that the outcome reaches the
route asynchronously; do not make the engine depend on a synchronous return. The official advanced page uses a
minimalist engine built on fs.readFile and string replacement to show the contract. The example below follows the same
shape with an escape step added, because a hand-written engine that does not escape is a cross-site scripting hole.
// markdown-ish engine in the spirit of the official "markdown" and "template engine" examples
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import express from 'express';
const here = path.dirname(fileURLToPath(import.meta.url));
const escapeHtml = (value) =>
String(value)
.replaceAll('&', '&')
.replaceAll('<', '<')
.replaceAll('>', '>')
.replaceAll('"', '"')
.replaceAll("'", ''');
// {{name}} is escaped, {{{name}}} is trusted HTML
export function simpleEngine(filePath, options, done) {
readFile(filePath, 'utf8').then(
(source) => {
const rendered = source
.replace(/\{\{\{\s*(\w+)\s*\}\}\}/g, (_m, key) => String(options[key] ?? ''))
.replace(/\{\{\s*(\w+)\s*\}\}/g, (_m, key) => escapeHtml(options[key] ?? ''));
done(null, rendered);
},
(err) => done(err),
);
}
const app = express();
app.engine('bk', simpleEngine); // .bk files go through simpleEngine
app.set('views', path.join(here, 'views'));
app.set('view engine', 'bk');
app.get('/', (req, res) => {
res.render('welcome', { title: 'Bookshelf', intro: '<em>trusted markup</em>', who: req.query.who ?? 'reader' });
});
app.listen(3000);
With views/welcome.bk containing <h1>{{title}}</h1><p>{{{intro}}}</p><p>Hello, {{who}}</p> as its markup, a request
for /?who=%3Cscript%3E shows the characters instead of running a script. Source:
Developing template engines for Express and
expressjs/express: examples/ejs and
examples/markdown.
The markdown example in the Express repository registers an engine for .md files: it reads the file, replaces
{name} placeholders with escaped values from options, and converts the Markdown to HTML with the marked package.
The same structure applies to any format, such as turning a JSON document, a Markdown file or a SQL result into HTML.
Engines that expose __express or renderFile
An engine package can skip app.engine() for users by exporting a function named __express, with the same signature as
above. Express resolves it through require(ext).__express, which is why ejs and pug work with a bare
view engine setting. If you publish an engine, export __express (and a renderFile alias) and document the options
it reads from the object it receives.
Replacing the lookup: the view setting
By default a view is a file found in the views directories. The view setting replaces that lookup with a class of
your own. Express instantiates it as new View(name, { defaultEngine, root, engines }) and expects an instance with a
path property (a truthy value tells Express the view was found) and a render(options, callback) method. This lets
views come from a database, a cache or an object store, as the view-constructor example of the Express repository does
with templates fetched over HTTP.
// view-constructor.mjs - views kept in memory instead of on disk
import express from 'express';
const templates = new Map([
['greeting', 'Hello, {{name}}!'],
['empty', 'Nothing to show.'],
]);
class MemoryView {
constructor(name, options) {
this.name = name;
this.engine = options.engines['.txt'] ?? null;
// `path` must be truthy when the view exists, falsy otherwise
this.path = templates.has(name) ? name : undefined;
}
render(options, callback) {
const source = templates.get(this.name);
const rendered = source.replace(/\{\{(\w+)\}\}/g, (_m, key) => String(options[key] ?? ''));
// Express expects the callback asynchronously
queueMicrotask(() => callback(null, rendered));
}
}
const app = express();
app.set('view', MemoryView);
app.get('/', (req, res) => {
res.type('text').render('greeting', { name: 'reader' });
});
app.listen(3000);
The output is plain text in this example, so the template substitution does not escape; a custom View that serves HTML
must escape exactly as an engine does.
Output escaping and cross-site scripting
Every template engine’s central safety feature is automatic escaping: the data is converted so that the characters
<, >, &, " and ' appear as text and never as markup. Escaping is what prevents a book title, a username or a
review comment from running as a script in another user’s browser (stored cross-site scripting). Every engine in
Choosing an engine: Pug, EJS, Handlebars and Nunjucks escapes by default in its ordinary output syntax, and each one has a clearly named raw form. The rules that
follow are the same for all of them.
-
Use the escaped form for everything dynamic. The raw form is for markup produced by your own code, for example a rendered partial or a sanitized fragment, never for request data, database content or error messages.
-
Escaping depends on context. HTML-text escaping does not make a value safe inside a
<script>block, an inline event handler, astyleattribute or ajavascript:URL. Pass data to client scripts as JSON in adata-attribute or atype="application/json"script element whose content has<escaped, and validate URL schemes before putting them inhreforsrc. -
Sanitize rich text when you accept it. If users may submit HTML (a review with formatting), clean it on the server with a maintained sanitizer before it is stored or rendered raw. Escaping removes the formatting, sanitizing keeps a safe subset.
-
Add a Content-Security-Policy. A policy sent by Helmet limits what an injected script can do. This is a second layer; it is not a replacement for escaping. See Security Best Practices.
-
Never let a client pick the view or the engine options.
res.render(req.query.page)lets an attacker choose which file is read, and spreadingreq.queryorreq.bodyinto the options passes engine-reserved properties to the engine. EJS 3 releases before the fix for CVE-2022-29078 allowed remote code execution through thesettingsobject of a parsed query string when the application passed the whole query tores.render(). Express 5 changed the defaultquery parserto the simple one (node:querystring), which does not build the nested objects that attack needs, but the lesson stands: pass explicitly named values only.
The unescaped stack trace
A common mistake, and one of the errata in a book that shows an error page, is to print the stack trace of an error
through the raw output tag of EJS. A stack trace is not trusted text. It contains the error message, and the message
often contains input the attacker supplied: a validation message echoing a form field, a database error quoting a
query, a "cannot find file" error naming the requested path. Printed through the raw tag, <script> in that input
reaches the browser as markup and executes. A page meant to help the developer becomes an attack surface precisely when
something has already gone wrong. The two corrections are independent and should both be applied: render the trace (and
the message) through the escaped tag, and render it only outside production, with NODE_ENV checked as the
error-handler of the first example does. The default error handler of Express follows the same two rules: it escapes
the stack for its HTML response, and omits the stack when NODE_ENV is production.
Flash-style messages without connect-flash
A flash message is a notice that must survive exactly one redirect: after a form is submitted, the application
redirects to a page, and that page shows "Review saved" once. The classic implementation used connect-flash on top of
the session. The package is small and old; the technique takes a few lines of middleware over express-session, with
res.locals carrying the messages into the template.
// flash.mjs
import { fileURLToPath } from 'node:url';
import path from 'node:path';
import express from 'express';
import session from 'express-session';
const sessionSecret = process.env.SESSION_SECRET;
if (!sessionSecret) {
throw new Error('SESSION_SECRET must be set');
}
const here = path.dirname(fileURLToPath(import.meta.url));
const app = express();
app.set('views', path.join(here, 'views'));
app.set('view engine', 'ejs');
app.use(express.urlencoded({ extended: false }));
app.use(session({
secret: sessionSecret,
resave: false,
saveUninitialized: false,
cookie: { httpOnly: true, sameSite: 'lax', secure: process.env.NODE_ENV === 'production' },
}));
// 1. add req.flash(type, message) and move queued messages into res.locals
app.use((req, res, next) => {
req.flash = (type, message) => {
req.session.flash ??= [];
req.session.flash.push({ type, message });
};
res.locals.flash = req.session.flash ?? [];
delete req.session.flash; // consumed: shown by this response only
next();
});
app.get('/reviews/new', (req, res) => {
res.render('reviews/new', { title: 'New review' });
});
// 2. post-redirect-get: store the notice, redirect, show it once
app.post('/reviews', (req, res) => {
const text = String(req.body.text ?? '').trim();
if (text === '') {
req.flash('error', 'The review cannot be empty.');
return res.redirect('/reviews/new');
}
req.flash('success', 'Review saved.');
res.redirect('/reviews/new');
});
app.listen(3000);
In the layout the messages are printed with the escaped tag, one element per entry, for example
<% for (const m of flash) { %><p class="flash <%= m.type %>"><%= m.message %></p><% } %>. Note two details. The messages are
copied into res.locals and then deleted from the session before the response is rendered, so a refresh does not show
them again; and a redirect response carries no body, so a message queued in a request that sends a redirect is shown by
the following GET. For the production session store, the cookie flags and why saveUninitialized: false matters, see
Cookies & Sessions. Source:
Express middleware: session and
Express API: res.locals.
Caching, performance and production
-
Leave
view cacheat its default: on in production, off in development. Settingapp.enable('view cache')in development hides template edits until the process restarts; settingapp.disable('view cache')in production costs a file-system lookup per render. -
Turn on the engine’s own compiled-template cache in production as well. For EJS pass
cache: truethrough the options (Express passescache: truefor you when the view cache is on, and EJS honors it); Pug and Nunjucks read theNODE_ENV-driven flags described in their documentation. -
Do heavy work before rendering, not inside the template. Templates should receive finished values; a template that runs a query or sorts a thousand records on every request is a slow handler written in the wrong place.
-
Stream large pages with an engine that supports it, or render a shell and load the rest from a JSON endpoint; Express’s
res.render()produces one string, so it buffers the whole page. -
Compress the response in production (see Performance Best Practices) and cache fully static pages in front of Express rather than rendering them per request.
When server rendering is the right choice
Server rendering with a template engine fits a content-heavy application with a few interactive pieces: an admin area,
a reviews moderation screen, a marketing site, the pages of an application that must work without JavaScript. It gives
fast first paint, simple SEO and a single deployment. If most of the UI is interactive, a JSON API with a separate
front end, or a framework that renders React or Vue components on the server, is usually a better fit; see
SPAs & Server-Side Rendering. The choices are not exclusive:
a typical application renders a few pages with a template engine and serves a JSON API under /api from the same
process.
What changed recently
| Change | Effect |
|---|---|
Express 5 always renders asynchronously ( |
Code that depended on a synchronous callback must move into the callback or a promise wrapper. |
Express 5 default |
Nested |
|
Use |
Express website rebuilt on Astro in 2026; the template engines resource page was removed |
The guide page "Using template engines with Express" and the advanced page "Developing template engines for Express" remain the official references; the old community list of engines is no longer maintained. |
Node.js 18 minimum for Express 5 |
Built-in |
Where the books are outdated
The books that cover server-rendered Express applications were written between 2014 and 2018 for Express 4. The table lists what to correct when following them; it describes the issues and does not reproduce their content.
| Older practice | Where it appears | Current situation |
|---|---|---|
Jade as the default engine of the generator |
Monteiro 2014 and 2016, Sharma chapter 2 |
Jade was renamed Pug in 2016 because of a trademark conflict. Use the |
|
Monteiro 2016, chapter 1 and 2 |
EJS 3 removed the directive. Use the |
Swig as a templating engine |
Monteiro 2016 |
Swig is no longer maintained. Nunjucks (same family of syntax) or another maintained engine replaces it. |
|
Several tutorials |
The package is unmaintained; use |
|
Monteiro 2014 and 2016 |
Both still work but are small and little maintained; the middleware above does the same in a few lines on top of
|
|
Most examples |
Always asynchronous in Express 5; use the callback or |
Printing a stack trace or an error message through a raw EJS tag |
Monteiro 2016, chapter 7 |
A cross-site scripting hole; see Output escaping and cross-site scripting. Escape it and hide it in production. |
|
Generator output in all books |
The built-in |
Passing |
Seen in form-handling examples |
Treated as unsafe, see Output escaping and cross-site scripting; name the values you pass. |