Async Rendering
Modern EJS supports async rendering. This is useful when templates need to fetch data or call async functions before outputting content.
const ejs = require('ejs')
async function render() {
const html = await ejs.renderFile('./views/dashboard.ejs', {
async fetchStats() {
const res = await fetch('https://api.example.com/stats')
return res.json()
}
})
console.log(html)
}
Inside the template, call the async function with await. EJS will wait for the promise to resolve before rendering that section.
<% const stats = await fetchStats() %>
Total users: <%= stats.users %>
Revenue: $<%= stats.revenue %>
Keep async operations out of templates when possible. Pass pre-fetched data instead. Use async rendering only when the template truly needs to control the fetch.
Helper Functions in Templates
Pass helper functions to your templates to keep logic clean and reusable.
const ejs = require('ejs')
const helpers = {
currency(amount) {
return '$' + amount.toFixed(2)
},
truncate(text, len) {
return text.length > len ? text.slice(0, len) + '...' : text
},
timeAgo(date) {
const seconds = Math.floor((Date.now() - date) / 1000)
if (seconds < 60) return seconds + 's ago'
if (seconds < 3600) return Math.floor(seconds / 60) + 'm ago'
return Math.floor(seconds / 3600) + 'h ago'
}
}
const html = ejs.render(template, { helpers, product })
Using helpers keeps your templates readable. Instead of inline math or string manipulation, you call a named function that describes what it does.
Template Inheritance Patterns
EJS doesn't have built-in inheritance like Pug or Nunjucks, but you can achieve it with includes and blocks.
// views/base.ejs
<%= title %>
<%- include('styles') %>
<%- include('header') %>
<%- defineContent && defineContent() %>
<%- include('footer') %>
A more practical approach uses a wrapper function in your route handler.
function layout(templatePath, data) {
const content = ejs.renderFile(templatePath, data)
return ejs.renderFile('./views/layout.ejs', {
...data,
body: content
})
}
app.get('/about', async (req, res) => {
const html = await layout('./views/about.ejs', {
title: 'About Us',
pageClass: 'about-page'
})
res.send(html)
})
This gives you a single layout template that wraps every page. Add more nesting levels for sub-layouts.
Try it Yourself →Macro-Like Patterns
Macros let you define reusable template fragments with parameters. EJS supports this through includes with local variables.
// views/macros/form-elements.ejs
<% function input(name, label, type = 'text', value = '') { %>
<% } %>
<% function textarea(name, label, rows = 4) { %>
<% } %>
Then in your page template, include the macros file and call the functions.
<%- include('macros/form-elements') %>
Conditional Includes
Sometimes you want to include a partial only under certain conditions. EJS makes this straightforward.
<% if (user) { %>
<%- include('partials/user-menu', { user }) %>
<% } else { %>
<%- include('partials/guest-menu') %>
<% } %>
<% if (featureFlags.darkMode) { %>
<% } %>
<% const sidebar = showSidebar ? 'partials/sidebar' : 'partials/empty' %>
<%- include(sidebar) %>
The third example assigns the include path to a variable. This is useful when the condition determines which partial to load, not whether to load one at all.
Composition with Filters
Chain data transformations before passing them to templates. Keep the template focused on presentation.
app.get('/blog', async (req, res) => {
const posts = await Post.find()
const ViewData = {
posts: posts.map(p => ({
...p.toObject(),
excerpt: p.body.slice(0, 200) + '...',
formattedDate: new Date(p.createdAt).toLocaleDateString(),
tagList: p.tags.join(', ')
})),
totalPosts: posts.length
}
res.render('blog', ViewData)
})
The template receives ready-to-render data. No slicing, formatting, or joining happens in the template itself. This separation makes both the route handler and template easier to maintain.