Views & Templating Overview
Server-side rendering (SSR) is the practice of building complete HTML on the server and sending it to the browser ready to display. Express does not ship with a templating language of its own; instead it provides a small, pluggable layer — a view engine — that takes a template file plus a JavaScript object of data and produces an HTML string. Understanding how the views directory, app.set, and res.render fit together lets you serve dynamic pages without bolting on a separate frontend framework.
What server rendering means
In a server-rendered flow the browser requests a URL, Express runs a route handler, the handler asks a view engine to combine a template with data, and the resulting HTML is returned in the response body. The client receives a finished document — no client-side JavaScript is required to see the content. This contrasts with client-side rendering, where the server sends a near-empty HTML shell and JavaScript fetches data and builds the DOM in the browser.
Browser ──GET /products──▶ Express route
│
▼
res.render("products", data)
│ template + data
▼
view engine → HTML
Browser ◀──200 text/html─────── response
SSR shines for content that should be indexable, fast to first paint, or usable without JavaScript: marketing pages, blogs, dashboards, server-rendered forms, and transactional emails. An API-only application that talks to a single-page app or mobile client has no use for views — it returns JSON and never calls res.render.
The views directory
By convention Express looks for template files in a views folder at the project root. Each file is named after the view you render and carries the extension of your chosen engine (for example index.ejs or layout.pug).
my-app/
├── app.js
├── package.json
└── views/
├── index.ejs
├── products.ejs
└── partials/
└── header.ejs
You can override the location with the views application setting, and you may even pass an array of directories — Express searches them in order until it finds a matching file.
const path = require("path");
app.set("views", path.join(__dirname, "templates"));
Registering a view engine with app.set
Two settings wire up rendering. The view engine setting names the default engine so you can render files by name without an extension, and the engine package must be installed. Most popular engines (EJS, Pug, Handlebars) follow the Express engine contract and need no extra glue.
npm install express ejs
const express = require("express");
const path = require("path");
const app = express();
app.set("views", path.join(__dirname, "views"));
app.set("view engine", "ejs"); // default extension for res.render
app.listen(3000, () => console.log("http://localhost:3000"));
With view engine set to ejs, calling res.render("index") resolves to views/index.ejs. To use an engine for a non-standard extension, register it explicitly with app.engine(ext, callback).
| Setting | Purpose | Example value |
|---|---|---|
views | Directory (or array) where templates live | path.join(__dirname, "views") |
view engine | Default engine and file extension | "ejs", "pug" |
view cache | Caches compiled templates (auto-on in production) | true / false |
Tip:
view cacheis enabled automatically whenNODE_ENV=production. In development it is off so template edits show up on the next request without a restart.
Rendering with res.render
res.render(view, locals) is the method that produces and sends HTML. The first argument is the view name (relative to the views directory); the second is an object of locals — the data your template can reference. Express compiles the template, runs it with the locals, sets Content-Type: text/html, and ends the response for you.
app.get("/products", async (req, res) => {
const products = await db.findProducts();
res.render("products", {
title: "Our Products",
products,
});
});
A matching views/products.ejs consumes those locals:
<h1><%= title %></h1>
<ul>
<% products.forEach((p) => { %>
<li><%= p.name %> — $<%= p.price %></li>
<% }) %>
</ul>
Output:
<h1>Our Products</h1>
<ul>
<li>Keyboard — $79</li>
<li>Mouse — $39</li>
</ul>
Values placed on app.locals are available to every view (handy for site name or current year), while res.locals holds per-request data set by middleware — both merge with the object you pass to res.render.
app.locals.siteName = "DevCraftly";
app.use((req, res, next) => {
res.locals.currentUser = req.user; // available in all views this request
next();
});
res.render accepts an optional callback as a third argument; when supplied, Express returns the rendered HTML to you instead of sending it, which is useful for generating email bodies or handling render errors yourself.
app.get("/preview", (req, res, next) => {
res.render("email", { name: "Ada" }, (err, html) => {
if (err) return next(err);
res.type("text/plain").send(html.length + " bytes generated");
});
});
When SSR makes sense versus an API
Choosing between server rendering and an API-only design is an architectural decision, not just a templating one.
| Concern | Server-rendered views | API-only (JSON) |
|---|---|---|
| Best for | Content sites, dashboards, forms | SPAs, mobile, microservices |
| SEO / first paint | Strong — HTML arrives complete | Weaker without separate SSR |
| Client | Any browser, no JS required | Requires a JS or native client |
| Response type | text/html via res.render | application/json via res.json |
Many real applications do both: render a few server pages and expose a JSON API under /api for interactive features. Express supports this happily — only the view-rendering routes need a view engine configured.
Warning: Express 5 changed view-engine error handling so that a render error always flows to your error-handling middleware. Make sure you have an error handler registered, or failed renders will surface as an unhandled rejection rather than a clean 500 page.
Best Practices
- Keep templates in a single
viewsdirectory and set its path withpath.join(__dirname, "views")so it is independent of the working directory. - Set
view engineonce at startup and render by name, letting Express resolve the extension. - Pass only the data a view needs through
res.renderlocals; useapp.localsfor global values andres.localsfor per-request middleware data. - Let production caching work for you — rely on
NODE_ENV=productionrather than togglingview cacheby hand. - Escape user-supplied values in templates (most engines escape
<%= %>-style output by default) to prevent XSS. - Reach for SSR when SEO, first paint, or no-JS support matters; choose an API-only design when a separate client owns the UI.