CodingNic

Server-Rendered Views

Handlebars: Syntax, Partials, and Layouts

Server-Rendered Views 15 min read

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 .hbs file 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-handlebars 9.

Installing express-handlebars

bash
npm install express-handlebars
javascript
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:

javascript
app.engine('hbs', engine({ extname: '.hbs' }));
app.set('view engine', 'hbs');
app.set('views', path.join(__dirname, 'views'));
html
<!-- views/home.hbs -->
<h1>Welcome, {{userName}}!</h1>
bash
curl http://localhost:4397/
text
<!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:

text
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:

html
<!-- views/home.handlebars -->
<h1>Welcome, {{userName}}!</h1>
javascript
app.get('/', (req, res) => {
  res.render('home', { userName: 'Erin' });
});
bash
curl -i http://localhost:4399/
text
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:

html
<!-- 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:

bash
curl http://localhost:4399/
text
<!DOCTYPE html>
<html>
<head><title></title></head>
<body>
  <h1>Welcome, Erin!</h1>
</body>
</html>
javascript
app.get('/', (req, res) => {
  res.render('home', { pageTitle: 'My App', userName: 'Erin' });
});
bash
curl http://localhost:4398/
text
<!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.

html
<!-- 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}}
javascript
app.get('/products', (req, res) => {
  res.render('products', { products, layout: false });
});
bash
curl http://localhost:4302/products
text
<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

html
<p>Escaped: {{userInput}}</p>
<p>Unescaped: {{{userInput}}}</p>
javascript
res.render('escaping', { userInput: '<strong>bold</strong>', layout: false });
text
<p>Escaped: &lt;strong&gt;bold&lt;/strong&gt;</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

html
<!-- views/partials/header.handlebars -->
<header>
  <h1>{{siteName}}</h1>
  <nav><a href="/">Home</a> | <a href="/about">About</a></nav>
</header>
html
<!-- views/partials/footer.handlebars -->
<footer>
  <p>&copy; {{year}} {{siteName}}</p>
</footer>
html
<!-- views/page-with-partials.handlebars -->
{{> header siteName=siteName}}
<main>
  <p>This is the main page content.</p>
</main>
{{> footer siteName=siteName year=year}}
javascript
res.render('page-with-partials', { siteName: 'DemoSite', year: 2026, layout: false });
bash
curl http://localhost:4305/with-partials
text
<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>&copy; 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

  1. Install express-handlebars, and confirm for yourself that rendering any view without a views/layouts/main.handlebars file present throws the ENOENT error shown above.
  2. Create the default layout with a shared <head> and page title, and render two different pages through it, passing a different pageTitle to each.
  3. Build a nav partial and include it in at least two different templates, passing any data it needs as named arguments.
  4. 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.
  5. Switch a small project over to the .hbs extension, updating app.engine(), engine({ extname: '.hbs' }), app.set('view engine', ...), and every .handlebars filename (including the layout), and confirm it still renders correctly.

Recap

  • express-handlebars requires a views/layouts/main.handlebars file before any view can render, missing it breaks every render with an ENOENT error.
  • 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.
  • .hbs is a common shorter alternative to the .handlebars extension, set via engine({ extname: '.hbs' }), as long as app.engine() and app.set('view engine', ...) both agree with it.

Next lesson: a quick CRUD example, building a small app with everything from this lesson.