Custom Adapters
Build your own template adapter
A template adapter is any object with a render method that takes { input, ctx } and returns { html, text?, subject? }. If you use a template engine that Better-Notify doesn't have a package for — Pug, EJS, Nunjucks, Svelte Email, or your own HTML builder — you can wrap it as an adapter.
The TemplateAdapter interface
Prop
Type
Where RenderedOutput is:
Prop
Type
Example: EJS adapter
import ejs from 'ejs';
import type { TemplateAdapter } from '@betternotify/email';
const ejsTemplate = <TInput>(
htmlSource: string,
textSource?: string,
): TemplateAdapter<TInput> => ({
render: async ({ input }) => {
const html = await ejs.render(htmlSource, input, { async: true });
const text = textSource ? await ejs.render(textSource, input, { async: true }) : undefined;
return { html, text };
},
});Usage:
const welcome = rpc
.email()
.input(z.object({ name: z.string(), verifyUrl: z.string().url() }))
.subject(({ input }) => `Welcome, ${input.name}!`)
.template(ejsTemplate(
'<h1>Welcome, <%= name %>!</h1><a href="<%= verifyUrl %>">Verify</a>',
'Welcome, <%= name %>! Verify: <%= verifyUrl %>',
));Example: file-based adapter
Load templates from disk at startup and render at send time:
import { readFileSync } from 'node:fs';
import type { TemplateAdapter } from '@betternotify/email';
const fileTemplate = <TInput>(
htmlPath: string,
renderFn: (source: string, input: TInput) => string,
): TemplateAdapter<TInput> => {
const source = readFileSync(htmlPath, 'utf-8');
return {
render: async ({ input }) => ({ html: renderFn(source, input) }),
};
};Using context
The ctx parameter gives the template access to the pipeline context — values added by middleware via next({ ... }). Use it for environment-specific data like base URLs or tenant branding:
const welcome: TemplateAdapter<{ name: string }> = {
render: async ({ input, ctx }) => {
const baseUrl = (ctx as { baseUrl: string }).baseUrl;
return {
html: `<a href="${baseUrl}/verify">Welcome, ${input.name}!</a>`,
text: `Welcome, ${input.name}! Verify: ${baseUrl}/verify`,
};
},
};Plain function shorthand
For simple templates, skip the adapter object and pass a function directly to .template():
.template(async ({ input }) => ({
html: `<p>Hello ${input.name}</p>`,
text: `Hello ${input.name}`,
}))This is equivalent to { render: async ({ input }) => ... }. Use the object form when templates live in separate files or when you need to share an adapter across procedures.