Handlebars
Build email templates with Handlebars
The @betternotify/handlebars adapter compiles Handlebars templates and renders them with the procedure's validated input at send time. Templates are pre-compiled at creation time for fast renders.
Install
pnpm add @betternotify/handlebars handlebarsBasic usage
import { z } from 'zod';
import { handlebarsTemplate } from '@betternotify/handlebars';
const welcome = rpc
.email()
.input(z.object({ name: z.string() }))
.subject(({ input }) => `Welcome, ${input.name}!`)
.template(handlebarsTemplate('<h1>Hello {{name}}</h1>'));The template receives the procedure's input as the Handlebars data context. All standard Handlebars syntax works: {{#each}}, {{#if}}, {{#unless}}, {{> partial}}, and custom helpers.
Text and subject templates
Pass text and subject options to render all three from a single adapter:
.template(handlebarsTemplate(
'<h1>Hello {{name}}</h1><ul>{{#each items}}<li>{{this}}</li>{{/each}}</ul>',
{
text: 'Hello {{name}}. Items: {{#each items}}{{this}} {{/each}}',
subject: 'Welcome, {{name}}!',
},
))All templates share the same Handlebars instance, so helpers and partials registered on the adapter are available in all three.
Helpers and partials
.template(handlebarsTemplate(
'<p>{{upper name}}</p><div>{{> footer}}</div>',
{
helpers: {
upper: (str: string) => str.toUpperCase(),
},
partials: {
footer: '<p style="color: gray">Sent by Acme Corp</p>',
},
},
))Each adapter gets its own isolated Handlebars instance — helpers and partials don't leak between templates.
API
handlebarsTemplate(source, opts?)
Prop
Type
Returns a TemplateAdapter<TInput>.
When to use Handlebars
Handlebars is a good fit when you have existing .hbs templates from another system, when non-developers need to edit templates without touching TypeScript, or when you want logic-light templates with simple {{variable}} interpolation. For responsive email layouts, consider combining Handlebars with MJML — the MJML adapter uses Handlebars under the hood.