Handlebars: Syntax, Partials, and Layouts
Objectives
By the end of this lesson, you should be able to:
- Configure Express to use
express-handlebars, including its required default layout - Use
{{#each}}and{{#if}}for loops and conditionals inside a template - Reuse HTML across pages with partials
- Switch to the shorter
.hbsfile extension, if preferred
๐ก Why this matters: EJS isn’t the only Express view engine, Handlebars is another widely used alternative, built around “logic-less” templates, no embedded JavaScript, just a small set of built-in helpers. Recognizing it matters, existing projects using Handlebars are common enough that being unable to read one would be a real gap.
โ ๏ธ A note on verification: every snippet and every response shown below was actually run and tested with real HTTP requests, using
express-handlebars9.
Installing express-handlebars
npm install express-handlebars
const express = require('express');
const { engine } = require('express-handlebars');
const path = require('path');
const app = express();
app.engine('handlebars', engine());
app.set('view engine', 'handlebars');
app.set('views', path.join(__dirname, 'views'));
app.engine('handlebars', engine()) registers the engine explicitly, unlike EJS (Module 6, Lesson 1), Handlebars needs this one extra line, express-handlebars isn’t auto-detected the way EJS is.
A Shorter File Extension: .hbs
Typing .handlebars on every single file gets tedious, express-handlebars accepts an extname option to use a shorter extension instead, .hbs is the common choice:
app.engine('hbs', engine({ extname: '.hbs' }));
app.set('view engine', 'hbs');
app.set('views', path.join(__dirname, 'views'));
<!-- views/home.hbs -->
<h1>Welcome, {{userName}}!</h1>
curl http://localhost:4397/
<!DOCTYPE html>
<html>
<head><title>My App</title></head>
<body>
<h1>Welcome, Erin!</h1>
</body>
</html>
Three things change together: the string passed to app.engine(), the extname option passed to engine(), and app.set('view engine', ...), all three need to agree, 'hbs' in this example, rather than 'handlebars'. Everything else, layouts, partials, res.render(), {{#each}}, works identically, .hbs also applies to layout and partial files, views/layouts/main.hbs instead of views/layouts/main.handlebars. This lesson continues using .handlebars (the default, and still the more explicit, self-documenting choice), but either is a matter of team preference, pick one and use it consistently across a project.
Project File Structure
Before writing any template, here’s every file this lesson builds, and where it lives, views/layouts holds layouts, views/partials holds partials, everything else directly under views is a regular page template:
project/
โโโ package.json
โโโ server.js
โโโ views/
โโโ layouts/
โ โโโ main.handlebars <- required, wraps every page automatically
โโโ partials/
โ โโโ header.handlebars
โ โโโ footer.handlebars
โโโ home.handlebars
โโโ products.handlebars
โโโ escaping.handlebars
โโโ page-with-partials.handlebars
express-handlebars finds each piece by convention, no manual registration needed beyond app.set('views', ...): any .handlebars file directly under views is a page, rendered with res.render('name', data) using its filename (without the extension), anything under views/layouts is a layout, and anything under views/partials is a partial, available via {{> name}} from any page. The layouts/main.handlebars file matters most, without it, nothing below can render at all, which is exactly what the next section shows.
The Default Layout Is Required First
Unlike EJS, express-handlebars expects a layout to exist before it can render anything at all, by default it looks for views/layouts/main.handlebars and wraps every view in it automatically. Trying to render a view without one first:
<!-- views/home.handlebars -->
<h1>Welcome, {{userName}}!</h1>
app.get('/', (req, res) => {
res.render('home', { userName: 'Erin' });
});
curl -i http://localhost:4399/
HTTP/1.1 500 Internal Server Error
Error: ENOENT: no such file or directory, open '/tmp/node106c/views/layouts/main.handlebars'
This is the single most common first mistake with express-handlebars, a missing layout file breaks every single render in the app, not just one page. The fix is creating it before writing any view at all:
<!-- views/layouts/main.handlebars -->
<!DOCTYPE html>
<html>
<head><title>{{pageTitle}}</title></head>
<body>
{{{body}}}
</body>
</html>
{{{body}}} (triple braces, covered in full below) marks where each page’s own content gets inserted, this is what actually makes the layout work, without it, the page content would never appear anywhere in the output.
Rendering Through the Layout
With views/layouts/main.handlebars now in place, the exact same route from above works:
curl http://localhost:4399/
<!DOCTYPE html>
<html>
<head><title></title></head>
<body>
<h1>Welcome, Erin!</h1>
</body>
</html>
app.get('/', (req, res) => {
res.render('home', { pageTitle: 'My App', userName: 'Erin' });
});
curl http://localhost:4398/
<!DOCTYPE html>
<html>
<head><title>My App</title></head>
<body>
<h1>Welcome, Erin!</h1>
</body>
</html>
Every res.render() call is automatically wrapped in this layout, {{pageTitle}} inside the layout reads from the same data object passed to res.render(), exactly like any other Handlebars expression, this is why the <title> was empty in the first response (no pageTitle was passed) and filled in once it was.
Loops and Conditionals
The examples below use { layout: false } in res.render(), skipping the default layout so the output stays focused on just the piece being demonstrated, this option is covered in full at the end of this lesson.
<!-- views/products.handlebars -->
<h1>Products</h1>
{{#if products.length}}
<ul>
{{#each products}}
<li>
{{this.name}} - ${{this.price}}
{{#if this.inStock}}
<strong>(in stock)</strong>
{{else}}
(out of stock)
{{/if}}
</li>
{{/each}}
</ul>
{{else}}
<p>No products found.</p>
{{/if}}
app.get('/products', (req, res) => {
res.render('products', { products, layout: false });
});
curl http://localhost:4302/products
<h1>Products</h1>
<ul>
<li>Keyboard - $45 <strong>(in stock)</strong></li>
<li>Monitor - $210 (out of stock)</li>
</ul>
{{#each array}}...{{/each}} loops over array, this inside the block refers to the current item. EJS’s equivalent (Module 6, Lesson 2) was a real .forEach() call, Handlebars instead has loop and conditional syntax built directly into its templating language, “logic-less” here means templates use built-in helpers like #each and #if rather than arbitrary embedded JavaScript.
Escaped vs Unescaped Output
<p>Escaped: {{userInput}}</p>
<p>Unescaped: {{{userInput}}}</p>
res.render('escaping', { userInput: '<strong>bold</strong>', layout: false });
<p>Escaped: <strong>bold</strong></p>
<p>Unescaped: <strong>bold</strong></p>
{{ }} escapes HTML automatically, exactly the same safe default as EJS’s <%= %> (Module 6, Lesson 2, still the right default for anything from user input). Triple braces, {{{ }}}, output raw, unescaped HTML, this is exactly the mechanism the layout above relies on for {{{body}}}, the rendered page’s HTML needs to be inserted as real markup, not escaped text.
Partials
<!-- views/partials/header.handlebars -->
<header>
<h1>{{siteName}}</h1>
<nav><a href="/">Home</a> | <a href="/about">About</a></nav>
</header>
<!-- views/partials/footer.handlebars -->
<footer>
<p>© {{year}} {{siteName}}</p>
</footer>
<!-- views/page-with-partials.handlebars -->
{{> header siteName=siteName}}
<main>
<p>This is the main page content.</p>
</main>
{{> footer siteName=siteName year=year}}
res.render('page-with-partials', { siteName: 'DemoSite', year: 2026, layout: false });
curl http://localhost:4305/with-partials
<header>
<h1>DemoSite</h1>
<nav><a href="/">Home</a> | <a href="/about">About</a></nav>
</header>
<main>
<p>This is the main page content.</p>
</main>
<footer>
<p>© 2026 DemoSite</p>
</footer>
express-handlebars automatically registers every file inside views/partials as a partial, keyed by its filename without the extension, header.handlebars becomes the partial header. {{> header siteName=siteName}} includes it, passing siteName=siteName explicitly, unlike EJS’s include() (Module 6, Lesson 4), which received a whole data object, Handlebars partials receive named arguments one at a time, key=value, only what’s explicitly passed is available inside the partial.
Overriding or Skipping the Layout
{ layout: false }, used throughout this lesson’s examples, skips the default layout entirely for that one render, useful when a response is a fragment rather than a full page (an AJAX-loaded snippet, or, as here, keeping an example focused). Passing { layout: 'plain' } instead swaps in a different layout file (views/layouts/plain.handlebars), rather than skipping layouts altogether, useful for a page that needs a different shell, a login page without the usual navigation, for example.
Try It
- Install
express-handlebars, and confirm for yourself that rendering any view without aviews/layouts/main.handlebarsfile present throws theENOENTerror shown above. - Create the default layout with a shared
<head>and page title, and render two different pages through it, passing a differentpageTitleto each. - Build a
navpartial and include it in at least two different templates, passing any data it needs as named arguments. - Render one page with
{ layout: false }and confirm the layout’s<html>/<head>/<body>wrapper is absent from the response, only the page’s own content comes back. - Switch a small project over to the
.hbsextension, updatingapp.engine(),engine({ extname: '.hbs' }),app.set('view engine', ...), and every.handlebarsfilename (including the layout), and confirm it still renders correctly.
Recap
express-handlebarsrequires aviews/layouts/main.handlebarsfile before any view can render, missing it breaks every render with anENOENTerror.app.engine('handlebars', engine())registers the engine,{{expression}}(escaped) and{{{expression}}}(unescaped) print values,{{#each}}/{{#if}}provide loops and conditionals without embedded JavaScript.- Partials live in
views/partials, auto-registered by filename, included with{{> name key=value}},{ layout: false }skips the default layout for a single render,{ layout: 'name' }swaps in a different one. .hbsis a common shorter alternative to the.handlebarsextension, set viaengine({ extname: '.hbs' }), as long asapp.engine()andapp.set('view engine', ...)both agree with it.
Next lesson: a quick CRUD example, building a small app with everything from this lesson.