Migrate to RunxBuild and earn up to $50 in hosting credit on your first deposit.

Calculate your savings
unxBuild

hapi Templates: Rendering Views with Vision, Handlebars, and a Layout That Works

Sean

Platform Writer

Sep 09, 2026
8 min read

hapi does not render templates on its own. Template rendering comes from vision, a first-party plugin that adds a views configuration to the server and an h.view method to your route handlers. Register vision, tell it which engine handles which file extension and where the templates live, and route handlers can return rendered HTML instead of JSON. Nearly every error people hit with hapi views is a path resolved against the wrong directory.

hapi Templates: Rendering Views with Vision, Handlebars, and a Layout That Works

Server-rendered HTML is unfashionable and extremely practical. For an admin panel, a set of transactional emails, or a marketing surface that has to work without JavaScript, rendering on the server is less machinery and fewer moving parts than the alternative. hapi handles it cleanly once the plugin registration is right.

Table of contents

Vision is the plugin that adds views

Install the plugin and an engine. Handlebars is the common choice; pug and ejs work identically as far as hapi is concerned.

npm install @hapi/hapi @hapi/vision handlebars
const Hapi = require('@hapi/hapi');
const Vision = require('@hapi/vision');
const Handlebars = require('handlebars');
const Path = require('path');

const start = async () => {
  const server = Hapi.server({ port: 3000, host: '0.0.0.0' });

  await server.register(Vision);

  server.views({
    engines: { hbs: Handlebars },
    relativeTo: __dirname,
    path: 'views',
    layoutPath: 'views/layouts',
    layout: 'default',
    partialsPath: 'views/partials',
    helpersPath: 'views/helpers',
  });

  server.route({
    method: 'GET',
    path: '/',
    handler: (request, h) => h.view('index', { title: 'Home' }),
  });

  await server.start();
  console.log('listening on', server.info.uri);
};

start();

Note the order. server.views cannot be called before vision is registered; doing so throws, and the message is clear enough that this mistake is a one-time cost.

The path options, which cause most of the errors

This is where the time goes, so it is worth being precise about what each option means.

  • relativeTo. The base directory every other path is resolved against. Set it to __dirname. Without it, paths resolve against the process working directory, which is wherever npm start was run from, which is why the app works locally and fails when started by a process manager.
  • path. Where the top-level templates live. h.view(‘index’) looks for path plus index plus the engine’s extension.
  • layoutPath. Where layout templates live. Separate from path so a layout is not itself renderable as a page.
  • layout. The default layout name, or true to use one called layout, or false for none.
  • partialsPath. Where reusable fragments live. Vision registers everything in here with the engine automatically.
  • helpersPath. Where helper modules live, each exporting a single function, named by its filename.

The single most common failure is a view not found error naming an absolute path you do not recognise. Read that path: it tells you exactly what relativeTo resolved to, and the fix is nearly always adding relativeTo: __dirname.

The engines key maps file extension to engine, so the extension is not part of the name you pass to h.view. Registering hbs means index.hbs on disk and h.view(‘index’) in the handler.

Layouts and partials

A layout is the page shell. The template being rendered is injected where the triple-brace body placeholder sits, which is unescaped on purpose because the content is already HTML.

<!-- views/layouts/default.hbs -->
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <title>{{title}}</title>
  </head>
  <body>
    {{> header}}
    <main>{{{content}}}</main>
    {{> footer}}
  </body>
</html>

Partials are included with the angle-bracket syntax and are named by their path under partialsPath, without the extension. A partial at views/partials/nav/main.hbs is included as nav/main.

Two practical notes. A layout can be overridden per route by passing options to h.view, which is how you render a page with no shell at all. And partials are registered at server start, so adding a new partial file requires a restart, which is the cause of the mysterious missing partial error after creating one.

// Render without the default layout, for a fragment or an email body
h.view('email/receipt', { order }, { layout: false });

Passing data, and where the context comes from

The second argument to h.view is the context. Everything the template can see comes from that object, plus anything in the global context if you configured one.

server.route({
  method: 'GET',
  path: '/orders/{id}',
  handler: async (request, h) => {
    const order = await db.getOrder(request.params.id);

    if (!order) {
      return h.view('errors/404').code(404);
    }

    return h.view('orders/show', {
      title: 'Order ' + order.reference,
      order,
      lines: order.lines,
      isPaid: order.status === 'paid',
    });
  },
});

Handlebars is deliberately logic-light: no arbitrary expressions, no method calls with arguments in the template. That constraint is a feature. It pushes decisions like isPaid into the handler where they are testable, rather than into a template where they are not.

A global context is useful for things every page needs, and it is a function so it can read per-request state.

server.views({
  // ...engine and path options...
  context: (request) => ({
    user: request.auth.isAuthenticated ? request.auth.credentials : null,
    year: new Date().getFullYear(),
  }),
});

Helpers, and the escaping rule that matters

A helper is a function callable from a template. Put one file per helper in helpersPath, exporting a single function; the filename becomes the helper name.

// views/helpers/currency.js
module.exports = (cents) => {
  if (typeof cents !== 'number') return '';
  return '$' + (cents / 100).toFixed(2);
};

Called from a template as a double-brace expression with the helper name and its argument. The escaping rule is the important part and it is easy to get wrong.

  • Double braces escape the output. This is the default and it is what you want for anything derived from user input.
  • Triple braces do not escape. Use them only for content you generated yourself and know is safe HTML, such as the layout body placeholder.
  • A helper returning a SafeString bypasses escaping regardless of the braces. Reserve that for helpers that genuinely emit markup, and never build one by concatenating a user-supplied value into a string.

Every cross-site scripting bug in a server-rendered template comes from one of the last two lines. If a helper takes user input and returns markup, it needs to escape the input itself before wrapping it.

Caching, and why development feels stale

Vision caches compiled templates by default. In production this is exactly right. In development it means edits do not appear until the process restarts, which people usually diagnose as a build problem.

server.views({
  // ...engine and path options...
  isCached: process.env.NODE_ENV === 'production',
});

Note that this only affects template compilation. Partials and helpers are loaded at registration time and are not re-scanned, so adding a new file in either directory still needs a restart even with caching off.

For production the other thing worth setting is a sensible cache-control policy on rendered pages. Rendered HTML is usually private or short-lived, and the default of no explicit header means intermediaries guess.

return h.view('dashboard', context)
  .header('Cache-Control', 'private, no-store');

How this fits the rest of the stack

A hapi app rendering server-side templates is a long-running Node process, which means the deployment question is a service rather than a static bundle, plus a database if the views are reading from one. The RunxBuild hosting calculator puts those next to each other so the total is a set of visible line items rather than an estimate. On RunxBuild a Node service deploys from a GitHub repository with build logs, environment variables, a live route and persistent storage if the app writes files, with a managed MySQL or Postgres instance on the private network beside it.

Useful related references:

FAQ

Does hapi have templating built in?

No. Template rendering comes from the vision plugin, which is maintained alongside hapi itself. Register vision, call server.views to configure engines and paths, and h.view becomes available in route handlers.

Why does hapi say my view was not found?

Read the absolute path in the error. It shows what relativeTo resolved to, and the cause is nearly always a missing relativeTo: __dirname, which leaves paths resolving against the process working directory rather than the source folder.

Can I use pug or ejs instead of handlebars with hapi?

Yes. The engines option maps a file extension to any engine vision supports, and several can be registered at once so different templates use different engines. The rest of the configuration is unchanged.

Why do my template changes not appear until I restart?

Vision caches compiled templates by default. Set isCached to false outside production. New partial and helper files still require a restart either way, because those directories are scanned once at registration.

What is the difference between two and three braces in a handlebars template?

Two braces escape the output for safe HTML insertion, which is what you want for anything derived from user input. Three braces insert the value raw. Use three only for markup you generated and trust, such as the layout body placeholder.

#hapi templates#hapi vision#handlebars#server side rendering#hapi views