The Three Ways to Pass Data
Express provides three mechanisms to pass data to your views:
| Method | Scope | When to Use |
|---|---|---|
res.render() options | Per-request | Page-specific data (user, posts, etc.) |
res.locals | Per-request | Middleware-set data (auth, flash messages) |
app.locals | Application-wide | Globals (site name, version, constants) |
app.locals Global data (available everywhere)
│
├─ route /home
│ ├─ res.locals Request data (set by middleware)
│ └─ res.render('home', { ... }) Route-specific data
│
├─ route /profile
│ ├─ res.locals
│ └─ res.render('profile', { ... })
│
└─ route /about
├─ res.locals
└─ res.render('about', { ... })
1. Passing Data via res.render()
The most common way - pass an object as the second argument to res.render():
app.get('/profile/:id', (req, res) => {
const user = {
id: req.params.id,
name: 'Alice',
email: 'alice@example.com',
bio: 'Full-stack developer',
joined: new Date('2024-01-15')
};
const stats = {
posts: 42,
followers: 128,
following: 89
};
const recentPosts = [
{ title: 'Express Tips', slug: 'express-tips', views: 1200 },
{ title: 'Node.js Best Practices', slug: 'node-best-practices', views: 3400 }
];
res.render('profile', {
title: `${user.name}'s Profile`,
user,
stats,
recentPosts,
isOwnProfile: req.user?.id === user.id,
currentYear: new Date().getFullYear()
});
});
Accessing in Templates
EJS:
<h1><%= user.name %></h1>
<p><%= user.email %></p>
<p><%= stats.posts %> posts published</p>
<% if (isOwnProfile) { %>
<a href="/settings">Edit Profile</a>
<% } %>
Pug:
h1= user.name
p= user.email
p #{stats.posts} posts published
if isOwnProfile
a(href="/settings") Edit Profile
Handlebars:
<h1>{{user.name}}</h1>
<p>{{user.email}}</p>
<p>{{stats.posts}} posts published</p>
{{#if isOwnProfile}}
<a href="/settings">Edit Profile</a>
{{/if}}
2. Passing Data via res.locals
res.locals is an object that exists for the duration of a single request. Data set there is available to all templates rendered during that request.
Setting in Middleware
// Auth middleware - attach user to every view
function attachUser(req, res, next) {
if (req.session?.userId) {
res.locals.user = getUser(req.session.userId);
res.locals.isAuthenticated = true;
} else {
res.locals.user = null;
res.locals.isAuthenticated = false;
}
next();
}
app.use(attachUser);
// Now every template can access user and isAuthenticated
// without passing them explicitly in res.render()
Flash Messages Middleware
function flashMessages(req, res, next) {
res.locals.success = req.session?.success || null;
res.locals.error = req.session?.error || null;
// Clear after reading
if (req.session) {
delete req.session.success;
delete req.session.error;
}
next();
}
app.use(flashMessages);
// Route
app.post('/login', (req, res) => {
if (validLogin(req.body)) {
req.session.success = 'Logged in successfully!';
res.redirect('/dashboard');
} else {
req.session.error = 'Invalid credentials';
res.redirect('/login');
}
});
Template Usage
<!-- Available in any view without passing explicitly -->
<% if (isAuthenticated) { %>
<p>Welcome, <%= user.name %>!</p>
<% } %>
<% if (success) { %>
<div class="alert alert-success"><%= success %></div>
<% } %>
<% if (error) { %>
<div class="alert alert-error"><%= error %></div>
<% } %>
3. Passing Data via app.locals
app.locals holds data that’s available across all requests - it persists for the app’s lifetime.
Setting Globals
app.locals.siteName = 'My Express App';
app.locals.siteVersion = '2.0.0';
app.locals.supportEmail = 'support@myapp.com';
app.locals.currentYear = new Date().getFullYear();
app.locals.features = {
darkMode: true,
comments: true,
analytics: false
};
app.locals.menu = [
{ label: 'Home', path: '/' },
{ label: 'About', path: '/about' },
{ label: 'Contact', path: '/contact' }
];
Dynamic app.locals (on startup)
async function initializeApp() {
const config = await loadConfig();
const theme = await getThemeSettings();
const categories = await getCategories();
app.locals.config = config;
app.locals.theme = theme;
app.locals.categories = categories;
app.locals.startupTime = new Date();
}
initializeApp();
Accessing Globals in Templates
<!-- EJS -->
<footer>
© <%= currentYear %> <%= siteName %>. All rights reserved.
<a href="mailto:<%= supportEmail %>">Support</a>
</footer>
<!-- Pug -->
footer
| © #{currentYear} #{siteName}. All rights reserved.
a(href='mailto:' + supportEmail) Support
<!-- Handlebars -->
<footer>
© {{currentYear}} {{siteName}}. All rights reserved.
<a href="mailto:{{supportEmail}}">Support</a>
</footer>
Precedence and Merging
When the same key exists in multiple places, more specific wins:
app.locals.theme = 'dark' # Base default
res.locals.theme = 'light' # Override for this request
res.render('page', { theme: 'custom' }) # Override for this render
Precedence order (highest wins):
res.render()optionsres.localsapp.locals
Practical Example: Full Data Flow
// app.js
const express = require('express');
const path = require('path');
const app = express();
app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, 'views'));
// Global data
app.locals.siteName = 'TechBlog';
app.locals.currentYear = new Date().getFullYear();
app.locals.categories = ['JavaScript', 'Node.js', 'Express', 'Database'];
// Middleware: request data
app.use((req, res, next) => {
res.locals.currentPath = req.path;
res.locals.query = req.query;
res.locals.isHome = req.path === '/';
next();
});
// Middleware: auth data
app.use((req, res, next) => {
const token = req.cookies?.token;
if (token) {
res.locals.user = { name: 'Alice', email: 'alice@test.com' };
res.locals.isAuthenticated = true;
} else {
res.locals.user = null;
res.locals.isAuthenticated = false;
}
next();
});
// Route: page-specific data
app.get('/blog/:slug', (req, res) => {
const post = {
title: 'Understanding Express Middleware',
slug: req.params.slug,
body: 'Express middleware is...',
author: 'Alice',
published: new Date('2026-06-10'),
tags: ['express', 'middleware'],
views: 1542
};
res.render('blog/post', {
title: post.title,
post,
relatedPosts: getRelatedPosts(post.tags)
});
});
views/blog/post.ejs
<!DOCTYPE html>
<html>
<head>
<title><%= title %> - <%= siteName %></title>
</head>
<body>
<header>
<h1><a href="/"><%= siteName %></a></h1>
<nav>
<% categories.forEach(cat => { %>
<a href="/category/<%= cat %>" class="<%= currentPath.includes(cat) ? 'active' : '' %>">
<%= cat %>
</a>
<% }) %>
</nav>
<% if (isAuthenticated) { %>
<span>Welcome, <%= user.name %></span>
<% } %>
</header>
<main>
<article>
<h1><%= post.title %></h1>
<p class="meta">
By <%= post.author %> on <%= post.published.toDateString() %>
- <%= post.views %> views
</p>
<div class="content"><%= post.body %></div>
</article>
</main>
<footer>
<p>© <%= currentYear %> <%= siteName %></p>
</footer>
</body>
</html>
Best Practices
1. Use app.locals for truly global data
// ✅ Good
app.locals.siteName = 'MyApp';
// ❌ Not global
app.locals.currentUser = getUser(); // Different per request!
2. Use res.locals for middleware data
// ✅ Good
app.use((req, res, next) => {
res.locals.csrfToken = generateToken(req);
next();
});
// ❌ Don't set request-specific data in app.locals
app.locals.csrfToken = generateToken(); // Same for all users - defeats purpose
3. Decompose complex data in the route
// ✅ Good - prepare data before passing
app.get('/dashboard', async (req, res) => {
const [user, posts, notifications] = await Promise.all([
getUser(req.userId),
getUserPosts(req.userId),
getNotifications(req.userId)
]);
res.render('dashboard', {
user: user.toJSON(),
posts: posts.map(p => p.toJSON()),
notifications: notifications.slice(0, 5),
unreadCount: notifications.filter(n => !n.read).length
});
});
4. Set defaults, then override
app.use((req, res, next) => {
// Default values for every view
res.locals.title = 'MyApp';
res.locals.description = 'Default description';
res.locals.ogImage = '/images/default-og.png';
next();
});
// Routes override as needed
app.get('/about', (req, res) => {
res.render('about', {
title: 'About Us - MyApp',
description: 'Learn more about our team and mission'
});
});
Debugging Data in Templates
Temporarily dump all available data during development:
EJS
<pre><%= JSON.stringify(locals, null, 2) %></pre>
Pug
pre= JSON.stringify(locals, null, 2)
Handlebars
<pre>{{json locals}}</pre>
hbs.registerHelper('json', (context) => JSON.stringify(context, null, 2));
Key Takeaways
res.render('view', { data })passes page-specific datares.localspasses request-scoped data (ideal for middleware)app.localspasses application-wide globals- Precedence:
res.render()>res.locals>app.locals - Use middleware to attach common data (user, flash messages, CSRF tokens)
- Decompose and transform data before passing to views
- Dump
localsduring development to debug available data