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 views setting, the directory (or array of directories) searched for templates;

  • the view engine setting, the default file extension, so that res.render('index') finds index.ejs or index.pug;

  • app.engine(ext, callback), which registers the function that renders files with a given extension;

  • res.render(view, locals, callback) and app.render(view, locals, callback), which look the template up, merge the data and call the engine;

  • app.locals and res.locals, the two places where data shared by many templates lives;

  • the view cache setting, 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

views

A directory, or an array of directories searched in order for templates.

process.cwd() + '/views'. The default depends on the directory the process was started in, so an application started from another directory fails to find its templates. Set it to an absolute path built from the module location.

view engine

The engine extension used when a view name has none, without the leading dot.

Not set. Without it, res.render('index') throws unless the name carries an extension.

view cache

Caches the resolved View object (file lookup and the engine function), so the file system is not searched on every request.

Enabled when NODE_ENV is production, disabled otherwise. Whether the engine also caches the compiled template is the engine’s own option (cache for EJS, cache for Pug, noCache for Nunjucks).

view

The class used to represent a template. Advanced: replaces the default file-based lookup.

The built-in View. See Replacing the lookup: the view setting.

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 to views, 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 call next(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 an await on 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:

  1. app.locals, properties that live as long as the application. Express fills in settings with the application’s settings; authors add constants such as the site name, the build number or helper functions.

  2. 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.

  3. The locals argument of res.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.body or req.query object into locals or 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.

include('file', data) function. No inheritance; layouts are made from header and footer includes, or with the community package express-ejs-layouts.

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 #{x} is escaped; !{x} is raw. Attribute values are escaped.

extends and block for real layout inheritance, include, mixins.

Dense markup, strong layout inheritance, compile-time checks. Whitespace-sensitive.

Handlebars (express-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 express-handlebars, helpers registered per engine.

Templates shared with a front end that uses Handlebars; designers who must not write code in templates.

Nunjucks

Jinja2-inspired syntax with filters, macros, extends, block and include.

autoescape option, on by default in current releases; a safe filter marks content as raw.

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 %}

Practical differences worth knowing

  • Several engines in one application. Each extension maps to one engine. app.engine('hbs', ...) and the built-in ejs can coexist; the extension in the view name (res.render('page.hbs')) selects the engine, and view engine is only the default for names without an extension.

  • ESM-only engines. The default lookup uses require to load the engine and read its __express export. A package that has become ESM-only, or that exports nothing named __express, cannot be found that way and must be registered explicitly with app.engine(), using an adapter function like the one in Developing a template engine with app.engine().

  • Layouts. Pug and Nunjucks have inheritance (extends, block); Handlebars gets layouts from express-handlebars; EJS has include() 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 include inherit 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=production when view cache is 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);

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):

  • filePath is the absolute path of the template file that was found;

  • options is the merged data: app.locals, then res.locals, then the locals argument, plus settings (the application’s settings) and, when the view cache is on, cache: true;

  • done is 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('&', '&amp;')
    .replaceAll('<', '&lt;')
    .replaceAll('>', '&gt;')
    .replaceAll('"', '&quot;')
    .replaceAll("'", '&#39;');

// {{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, a style attribute or a javascript: URL. Pass data to client scripts as JSON in a data- attribute or a type="application/json" script element whose content has < escaped, and validate URL schemes before putting them in href or src.

  • 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 spreading req.query or req.body into the options passes engine-reserved properties to the engine. EJS 3 releases before the fix for CVE-2022-29078 allowed remote code execution through the settings object of a parsed query string when the application passed the whole query to res.render(). Express 5 changed the default query parser to 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 cache at its default: on in production, off in development. Setting app.enable('view cache') in development hides template edits until the process restarts; setting app.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: true through the options (Express passes cache: true for you when the view cache is on, and EJS honors it); Pug and Nunjucks read the NODE_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 (res.render(), app.render())

Code that depended on a synchronous callback must move into the callback or a promise wrapper.

Express 5 default query parser is simple

Nested req.query objects and the engine-option injection that relied on them are gone by default. Do not turn the extended parser on without a reason.

res.send(status, body) and res.redirect(url, status) older forms removed

Use res.status(code).send(body) and res.redirect(code, url); relevant to the error pages that call render().

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 node: modules, fetch and top-level await are available in the examples; import.meta.dirname needs Node 20.11 or later, which is why the examples derive the directory with fileURLToPath.

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 pug package; the jade package is deprecated. Templates need small syntax updates (for example #{} interpolation is unchanged, but the jade extension becomes .pug).

<% include file %> directive in EJS

Monteiro 2016, chapter 1 and 2

EJS 3 removed the directive. Use the include() function, with the raw output tag when the included file is markup.

Swig as a templating engine

Monteiro 2016

Swig is no longer maintained. Nunjucks (same family of syntax) or another maintained engine replaces it.

consolidate used for every engine

Several tutorials

The package is unmaintained; use @ladjs/consolidate only for engines that have no Express integration of their own.

connect-flash and express-messages for notices

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 express-session.

res.render output assumed synchronous

Most examples

Always asynchronous in Express 5; use the callback or async wrappers.

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.

bodyParser plus express.static plus engine set-up pasted into one app.js by the generator

Generator output in all books

The built-in express.urlencoded() and express.json() replace the body-parser package; the generator itself is Express 4-era and still Jade-default.

Passing req.body or req.query straight to res.render

Seen in form-handling examples

Treated as unsafe, see Output escaping and cross-site scripting; name the values you pass.