Skip to content
Express.js ex templating 4 min read

Passing Data to Views

A template is only useful when it can render real data — a user’s name, a list of products, the current cart total. Express gives you several channels for feeding values into a view: the data object you pass to res.render, the per-request bag res.locals, and the app-wide bag app.locals. Knowing which one to reach for keeps route handlers clean and makes shared values (helpers, the logged-in user, the site name) available everywhere without repetition.

The render data object

The most direct way to pass data is the second argument to res.render. It is a plain JavaScript object whose keys become variables inside the template. The view engine merges this object with any locals (covered below) and exposes every property by name.

const express = require("express");
const app = express();

app.set("view engine", "ejs");

app.get("/products/:id", async (req, res) => {
  const product = await db.products.findById(req.params.id);
  res.render("product", {
    title: product.name,
    product,
    related: await db.products.related(product.id),
  });
});

Inside views/product.ejs those keys are referenced directly:

<h1><%= title %></h1>
<p>Price: $<%= product.price %></p>
<ul>
  <% related.forEach((r) => { %>
    <li><%= r.name %></li>
  <% }); %>
</ul>

res.render accepts an optional callback as its third argument. When supplied, Express hands you the rendered HTML instead of sending it automatically — useful for capturing output into an email or caching layer.

res.render("receipt", { order }, (err, html) => {
  if (err) return next(err);
  mailer.send(order.email, html);
  res.send("Receipt emailed.");
});

res.locals for per-request data

res.locals is an object scoped to a single request/response cycle. Anything you assign to it is automatically available in every view rendered during that request — you do not need to repeat it in each res.render call. This makes it the ideal place for middleware to publish request-specific context such as the authenticated user, flash messages, or the active navigation item.

app.use((req, res, next) => {
  res.locals.currentUser = req.user || null;
  res.locals.requestId = req.id;
  next();
});

app.get("/dashboard", (req, res) => {
  // currentUser is already available — no need to pass it again
  res.render("dashboard", { stats: getStats() });
});

In the template, currentUser resolves even though the route never mentioned it:

<% if (currentUser) { %>
  <span>Hello, <%= currentUser.name %></span>
<% } %>

Because res.locals is recreated for every request, it is safe for user-specific data. Never store request data on app.locals — that object is shared across all requests and would leak one user’s data to another.

app.locals for app-wide values

app.locals is a single object that lives for the lifetime of the application. Its properties are exposed to every template in every request, making it the right home for constants that never vary per user: the site name, the current year, a CDN base URL, or a feature flag.

app.locals.siteName = "DevCraftly";
app.locals.year = new Date().getFullYear();
app.locals.assetHost = process.env.ASSET_HOST || "";
<footer>&copy; <%= year %> <%= siteName %></footer>

How the data sources merge

When res.render runs, Express builds the final template context by merging the three sources. Values defined later in the list override earlier ones, so a key passed to res.render wins over res.locals, which in turn wins over app.locals.

SourceScopeLifetimeTypical use
app.localsAll requestsApplicationSite name, year, config constants
res.localsOne requestSingle requestLogged-in user, flash messages, nav state
res.render dataOne render callSingle renderPage-specific records and lists

Precedence (lowest to highest): app.localsres.localsres.render object.

Exposing helpers to templates

Both locals objects can hold functions, which lets you expose reusable helpers — date formatting, currency, escaping — to every view. Because app.locals is global, it is the natural place for stateless formatting helpers.

const { format } = require("date-fns");

app.locals.formatDate = (d) => format(d, "MMM d, yyyy");
app.locals.money = (cents) => `$${(cents / 100).toFixed(2)}`;
<p>Posted <%= formatDate(post.createdAt) %></p>
<p>Total: <%= money(order.totalCents) %></p>

For helpers that need request context (such as building a URL with the current locale), attach them in middleware via res.locals instead, capturing req in a closure.

app.use((req, res, next) => {
  res.locals.url = (path) => `${req.protocol}://${req.get("host")}${path}`;
  next();
});

Express 5 behaves the same as 4.x for all of these locals mechanisms. The one change to watch is that Express 5 requires asynchronous errors thrown in route handlers to be passed to next (or thrown from an async handler, which 5 forwards automatically), so rendering failures surface consistently.

Best Practices

  • Pass only page-specific records through res.render; promote anything shared to res.locals or app.locals.
  • Use middleware to populate res.locals.currentUser once rather than threading the user through every render call.
  • Reserve app.locals for truly global, user-independent values and stateless helper functions.
  • Never store request- or user-specific data on app.locals — it is shared and will leak across requests.
  • Remember the precedence order: res.render data overrides res.locals, which overrides app.locals.
  • Provide sensible defaults (for example res.locals.currentUser = null) so templates can branch safely.
  • Keep template helpers small and pure so they are easy to test and reuse across views.
Last updated June 14, 2026
Was this helpful?