A Better Auth plugin that centralizes all email-sending callbacks through a single ESP-agnostic provider and template renderer.
Better Auth scatters email-sending across 8+ independent callback sites (4 core, 4+ plugin-level). Each must be independently wired to an email provider and template. This leads to duplicated send logic, inconsistent error handling, and templates spread across multiple locations.
better-email decouples what you send from how you send it through two independent interfaces:
- Provider (
EmailProvider) handles email delivery. Swap providers (Nuntly, SES, Resend, Postmark, Mailgun, SMTP) without touching a single template. - Renderer (
EmailTemplateRenderer) handles HTML/text generation. Switch from plain HTML to React Email or MJML without changing your provider config.
This separation means you can mix and match freely: use Postmark for delivery with React Email for templates, then later migrate to SES without rewriting any template code.
better-email also provides:
- 7 built-in providers: Nuntly (default), SES, Resend, Postmark, Mailgun, SMTP, Console
- 5 built-in renderers: plain HTML (default), React Email, MJML, Mustache, React MJML
- Core callback defaults injected via
init()forsendVerificationEmailandsendResetPassword - Factory wrappers for plugin-level callbacks (magic link, email OTP, organization invitation, two-factor OTP, change email, delete account)
- Lifecycle hooks (
onBeforeSend,onAfterSend,onSendError) applied consistently across all email types - Tag management with default tags and per-type tags for analytics/tracking
npm install @nuntly/better-email
# or
pnpm add @nuntly/better-email
# or
yarn add @nuntly/better-email
# or
bun add @nuntly/better-emailChoose one of the built-in providers or implement the EmailProvider interface. The default provider is NuntlyProvider.
Choose one of the built-in renderers or implement the EmailTemplateRenderer interface.
// lib/auth.ts
import { betterAuth } from 'better-auth';
import { organization, twoFactor } from 'better-auth/plugins';
import { magicLink } from 'better-auth/plugins/magic-link';
import { emailOTP } from 'better-auth/plugins/email-otp';
import { betterEmail, NuntlyProvider, DefaultTemplateRenderer } from '@repo/better-email';
const email = betterEmail({
provider: new NuntlyProvider({
apiKey: process.env.NUNTLY_API_KEY!,
from: 'noreply@yourdomain.com',
}),
templateRenderer: new DefaultTemplateRenderer(),
defaultTags: [{ name: 'app', value: 'my-app' }],
tags: {
'verification-email': [{ name: 'category', value: 'auth' }],
},
onAfterSend: async (context, message) => {
console.log(`Email sent: ${context.type} to ${message.to}`);
},
onSendError: async (context, message, error) => {
console.error(`Email failed: ${context.type} to ${message.to}`, error);
},
});
export const auth = betterAuth({
// ...your database, session, social providers config...
emailAndPassword: {
enabled: true,
},
emailVerification: {
sendOnSignUp: true,
},
user: {
changeEmail: {
enabled: true,
sendChangeEmailVerification: email.helpers.changeEmail,
},
deleteUser: {
enabled: true,
sendDeleteAccountVerification: email.helpers.deleteAccount,
},
},
plugins: [
email,
twoFactor({
sendOTP: email.helpers.twoFactor,
}),
organization({
sendInvitationEmail: email.helpers.invitation,
}),
magicLink({
sendMagicLink: email.helpers.magicLink,
}),
emailOTP({
sendVerificationOTP: email.helpers.otp,
}),
],
});// app/api/auth/[...all]/route.ts
import { auth } from '@/lib/auth';
import { toNextJsHandler } from 'better-auth/next-js';
export const { GET, POST } = toNextJsHandler(auth.handler);Providers handle delivery only. They receive a ready-to-send message (to, subject, html, text, tags) and deliver it through a service. They know nothing about templates or rendering.
All providers implement the EmailProvider interface:
interface EmailProvider {
send(message: EmailMessage): Promise<void>;
}Switching provider never requires changes to your templates. You can use a built-in provider or create your own by implementing the interface.
Sends emails via the Nuntly REST API. No external dependencies.
import { NuntlyProvider } from '@repo/better-email';
const provider = new NuntlyProvider({
apiKey: process.env.NUNTLY_API_KEY!,
from: 'noreply@yourdomain.com',
// Optional: defaults to https://api.nuntly.com
baseUrl: 'https://api.nuntly.com',
});| Option | Type | Required | Description |
|---|---|---|---|
apiKey |
string |
Yes | Your Nuntly API key. |
from |
string |
Yes | Sender email address. |
baseUrl |
string |
No | API base URL. Defaults to https://api.nuntly.com. |
Sends emails via AWS SES v2. Requires @aws-sdk/client-sesv2. The provider handles building the full SES payload internally.
import { SESProvider } from '@repo/better-email';
import { SESv2Client, SendEmailCommand } from '@aws-sdk/client-sesv2';
const provider = new SESProvider({
client: new SESv2Client({ region: 'us-east-1' }),
SendEmailCommand,
from: 'noreply@yourdomain.com',
configurationSetName: 'my-config-set', // optional
});| Option | Type | Required | Description |
|---|---|---|---|
client |
SESv2Client |
Yes | An SESv2Client instance from @aws-sdk/client-sesv2. |
SendEmailCommand |
class |
Yes | The SendEmailCommand class from @aws-sdk/client-sesv2. |
from |
string |
Yes | Sender email address. |
configurationSetName |
string |
No | SES configuration set name for tracking. |
Sends emails via the Resend REST API. No external dependencies.
import { ResendProvider } from '@repo/better-email';
const provider = new ResendProvider({
apiKey: process.env.RESEND_API_KEY!,
from: 'noreply@yourdomain.com',
});| Option | Type | Required | Description |
|---|---|---|---|
apiKey |
string |
Yes | Your Resend API key. |
from |
string |
Yes | Sender email address. |
baseUrl |
string |
No | API base URL. Defaults to https://api.resend.com. |
Sends emails via the Postmark REST API. No external dependencies.
import { PostmarkProvider } from '@repo/better-email';
const provider = new PostmarkProvider({
serverToken: process.env.POSTMARK_SERVER_TOKEN!,
from: 'noreply@yourdomain.com',
messageStream: 'outbound', // optional
});| Option | Type | Required | Description |
|---|---|---|---|
serverToken |
string |
Yes | Your Postmark server token. |
from |
string |
Yes | Sender email address. |
messageStream |
string |
No | Postmark message stream. |
baseUrl |
string |
No | API base URL. Defaults to https://api.postmarkapp.com. |
Sends emails via the Mailgun REST API. No external dependencies.
import { MailgunProvider } from '@repo/better-email';
const provider = new MailgunProvider({
apiKey: process.env.MAILGUN_API_KEY!,
domain: 'mg.yourdomain.com',
from: 'noreply@yourdomain.com',
// Optional: use EU region
baseUrl: 'https://api.eu.mailgun.net',
});| Option | Type | Required | Description |
|---|---|---|---|
apiKey |
string |
Yes | Your Mailgun API key. |
domain |
string |
Yes | Your Mailgun sending domain. |
from |
string |
Yes | Sender email address. |
baseUrl |
string |
No | API base URL. Defaults to https://api.mailgun.net. |
Sends emails via SMTP using a nodemailer transporter. Requires nodemailer. The provider handles message formatting internally.
import { SMTPProvider } from '@repo/better-email';
import nodemailer from 'nodemailer';
const provider = new SMTPProvider({
transporter: nodemailer.createTransport({
host: 'smtp.example.com',
port: 587,
auth: { user: 'user', pass: 'pass' },
}),
from: 'noreply@yourdomain.com',
});| Option | Type | Required | Description |
|---|---|---|---|
transporter |
nodemailer transporter | Yes | A pre-configured nodemailer transporter instance. |
from |
string |
Yes | Sender email address. |
Logs emails to the console instead of sending them. Useful for development and testing.
import { ConsoleProvider } from '@repo/better-email';
const provider = new ConsoleProvider();Implement the EmailProvider interface for any email service:
import type { EmailProvider, EmailMessage } from '@repo/better-email';
const customProvider: EmailProvider = {
async send(message: EmailMessage) {
await yourEmailApi.send({
to: message.to,
subject: message.subject,
html: message.html,
text: message.text,
});
},
};Renderers handle HTML/text generation only. They receive a typed context (user, url, token, etc.) and produce { subject, html, text }. They know nothing about how the email is delivered.
All renderers implement the EmailTemplateRenderer interface:
interface EmailTemplateRenderer {
render(context: EmailContext): Promise<RenderedEmail>;
}Switching renderer never requires changes to your transport config. The render method receives a discriminated union (EmailContext) where you switch on context.type to access type-specific fields.
Renders minimal plain HTML for all 8 email types. No dependencies required. Useful for prototyping and testing.
import { DefaultTemplateRenderer } from '@repo/better-email';
const renderer = new DefaultTemplateRenderer();Renders templates built with React MJML components. Requires react and @faire/mjml-react.
Automatic plain text generation: The renderer automatically converts the HTML output to plain text, preserving links, line breaks, and basic formatting.
import { ReactMJMLRenderer } from '@repo/better-email';
import { render } from '@faire/mjml-react';
import { createElement } from 'react';
import VerificationEmail from './emails/verification-mjml';
import ResetPasswordEmail from './emails/reset-password-mjml';
const renderer = new ReactMJMLRenderer({
render: (element) => render(element),
createElement,
templates: {
'verification-email': VerificationEmail,
'reset-password': ResetPasswordEmail,
},
subjects: {
'verification-email': 'Verify your email',
'reset-password': 'Reset your password',
},
});Each template component uses @faire/mjml-react MJML components and receives the typed context as props:
// emails/verification-mjml.tsx
import { Mjml, MjmlBody, MjmlSection, MjmlColumn, MjmlText } from '@faire/mjml-react';
import type { EmailProps } from '@repo/better-email';
export default function VerificationEmail({ user, url }: EmailProps<'verification-email'>) {
return (
<Mjml>
<MjmlBody>
<MjmlSection>
<MjmlColumn>
<MjmlText>Hi {user.name},</MjmlText>
<MjmlText>
Click <a href={url}>here</a> to verify your email.
</MjmlText>
</MjmlColumn>
</MjmlSection>
</MjmlBody>
</Mjml>
);
}| Option | Type | Required | Description |
|---|---|---|---|
render |
(element) => { html: string; errors: any[] } |
Yes | The render function from @faire/mjml-react. |
createElement |
(component, props) => any |
Yes | React.createElement. |
templates |
Partial<Record<EmailType, Component>> |
Yes | Map of email type to React MJML component. |
subjects |
Partial<Record<EmailType, string | Function>> |
Yes | Map of email type to subject line or function. |
fallback |
EmailTemplateRenderer |
No | Fallback renderer for missing templates. |
Renders templates built with React Email components. Requires react and @react-email/render.
Automatic plain text generation: The renderer automatically generates plain text versions of emails using { plainText: true } option. You can optionally provide a custom renderPlainText function for more control.
import { ReactEmailRenderer } from '@repo/better-email';
import { render } from '@react-email/render';
import { createElement } from 'react';
import VerificationEmail from './emails/verification';
import ResetPasswordEmail from './emails/reset-password';
const renderer = new ReactEmailRenderer({
render,
// Optional: Custom plain text renderer
// renderPlainText: (element) => render(element, { plainText: true }),
createElement,
templates: {
'verification-email': VerificationEmail,
'reset-password': ResetPasswordEmail,
},
subjects: {
'verification-email': 'Verify your email',
'reset-password': 'Reset your password',
},
});Each template component receives the typed context as props. Use the EmailProps<T> utility type to get the props for a given email type (strips the type discriminator automatically):
// emails/verification.tsx
import { Html, Head, Body, Text, Link } from '@react-email/components';
import type { EmailProps } from '@repo/better-email';
export default function VerificationEmail({ user, url }: EmailProps<'verification-email'>) {
return (
<Html>
<Head />
<Body>
<Text>Hi {user.name},</Text>
<Text>Click the link below to verify your email:</Text>
<Link href={url}>Verify email</Link>
</Body>
</Html>
);
}| Option | Type | Required | Description |
|---|---|---|---|
render |
(element, options?) => Promise<string> |
Yes | The render function from @react-email/render. |
createElement |
(component, props) => any |
Yes | React.createElement. |
templates |
Partial<Record<EmailType, Component>> |
Yes | Map of email type to React component. |
subjects |
Partial<Record<EmailType, string | Function>> |
Yes | Map of email type to subject line or function. |
renderPlainText |
(element) => Promise<string> | string |
No | Custom plain text renderer. Defaults to render(element, { plainText: true }). |
fallback |
EmailTemplateRenderer |
No | Fallback renderer for missing templates. |
Renders templates written in MJML markup. Requires the mjml package.
import { MJMLRenderer } from '@repo/better-email';
import mjml2html from 'mjml';
const renderer = new MJMLRenderer({
compile: (mjmlString) => mjml2html(mjmlString).html,
templates: {
'verification-email': (ctx) => ({
subject: 'Verify your email',
mjml: `
<mjml>
<mj-body>
<mj-section>
<mj-column>
<mj-text>
Click <a href="${ctx.url}">here</a> to verify your email.
</mj-text>
</mj-column>
</mj-section>
</mj-body>
</mjml>
`,
text: `Verify your email: ${ctx.url}`,
}),
'reset-password': (ctx) => ({
subject: 'Reset your password',
mjml: `
<mjml>
<mj-body>
<mj-section>
<mj-column>
<mj-text>
Click <a href="${ctx.url}">here</a> to reset your password.
</mj-text>
</mj-column>
</mj-section>
</mj-body>
</mjml>
`,
text: `Reset your password: ${ctx.url}`,
}),
},
});Each template function receives the typed EmailContext and returns { subject, mjml, text }.
Loading templates from files:
Since MJML templates are plain strings (not JavaScript template literals), you need a templating engine to inject dynamic values. Use Mustache syntax in your MJML files:
import { readFileSync } from 'fs';
import { join } from 'path';
import Mustache from 'mustache';
const loadTemplate = (filename: string) => readFileSync(join(__dirname, 'templates', filename), 'utf-8');
const renderer = new MJMLRenderer({
compile: (mjmlString) => mjml2html(mjmlString).html,
templates: {
'verification-email': (ctx) => {
// Template file uses Mustache syntax: <a href="{{url}}">
const mjmlTemplate = loadTemplate('verification.mjml');
const mjmlWithData = Mustache.render(mjmlTemplate, ctx);
return {
subject: 'Verify your email',
mjml: mjmlWithData,
text: `Verify your email: ${ctx.url}`,
};
},
},
});templates/verification.mjml:
<mjml>
<mj-body>
<mj-section>
<mj-column>
<mj-text>
Click <a href="{{url}}">here</a> to verify your email.
</mj-text>
</mj-column>
</mj-section>
</mj-body>
</mjml>| Option | Type | Required | Description |
|---|---|---|---|
compile |
(mjml: string) => string |
Yes | Compiles MJML to HTML. Wraps mjml2html(...).html. |
templates |
Partial<Record<EmailType, Function>> |
Yes | Map of email type to template function returning { subject, mjml, text }. |
fallback |
EmailTemplateRenderer |
No | Fallback renderer for missing templates. |
Renders templates using Mustache templating syntax. Requires the mustache package.
import { MustacheRenderer } from '@repo/better-email';
import Mustache from 'mustache';
const renderer = new MustacheRenderer({
render: (template, data) => Mustache.render(template, data),
templates: {
'verification-email': (ctx) => ({
subject: 'Verify your email',
template: `
<html>
<body>
<p>Click <a href="{{url}}">here</a> to verify your email.</p>
</body>
</html>
`,
text: `Verify your email: ${ctx.url}`,
}),
'reset-password': (ctx) => ({
subject: 'Reset your password',
template: `
<html>
<body>
<p>Click <a href="{{url}}">here</a> to reset your password.</p>
</body>
</html>
`,
text: `Reset your password: ${ctx.url}`,
}),
},
});Each template function receives the typed EmailContext and returns { subject, template, text }. The template string uses Mustache syntax ({{variable}}), and the full context is passed as data to Mustache.render.
Loading templates from files:
import { readFileSync } from 'fs';
import { join } from 'path';
const loadTemplate = (filename: string) => readFileSync(join(__dirname, 'templates', filename), 'utf-8');
const renderer = new MustacheRenderer({
render: (template, data) => Mustache.render(template, data),
templates: {
'verification-email': (ctx) => ({
subject: 'Verify your email',
template: loadTemplate('verification.mustache'), // {{url}} will be replaced automatically
text: `Verify your email: ${ctx.url}`,
}),
},
});templates/verification.mustache:
<html>
<body>
<p>Hi {{user.name}},</p>
<p>Click <a href="{{url}}">here</a> to verify your email.</p>
</body>
</html>The MustacheRenderer automatically passes the full context to Mustache.render(), so all fields (url, user.name, etc.) are available in your template.
| Option | Type | Required | Description |
|---|---|---|---|
render |
(template: string, data: Record<string, unknown>) => string |
Yes | Mustache render function. Wraps Mustache.render(template, data). |
templates |
Partial<Record<EmailType, Function>> |
Yes | Map of email type to template function returning { subject, template, text }. |
fallback |
EmailTemplateRenderer |
No | Fallback renderer for missing templates. |
Implement the EmailTemplateRenderer interface:
import type { EmailTemplateRenderer, EmailContext, RenderedEmail } from '@repo/better-email';
const customRenderer: EmailTemplateRenderer = {
async render(context: EmailContext): Promise<RenderedEmail> {
switch (context.type) {
case 'verification-email':
return {
subject: 'Verify your email',
html: `<p>Verify: <a href="${context.url}">${context.url}</a></p>`,
text: `Verify: ${context.url}`,
};
// handle other types...
default: {
const _exhaustive: never = context;
throw new Error(`Unhandled email type: ${(_exhaustive as EmailContext).type}`);
}
}
},
};The never check ensures TypeScript reports a compile-time error if a new email type is added to EmailContext without being handled in your renderer.
All built-in renderers accept an optional fallback renderer. If a template is not found for an email type, the fallback is used instead. This lets you use React Email for your main templates while the DefaultTemplateRenderer covers any types you haven't customized yet:
import { ReactEmailRenderer, DefaultTemplateRenderer } from '@repo/better-email';
const renderer = new ReactEmailRenderer({
render: (element, options) => render(element, options),
createElement,
templates: {
'verification-email': VerificationEmail,
'reset-password': ResetPasswordEmail,
},
subjects: {
'verification-email': 'Verify your email',
'reset-password': 'Reset your password',
},
fallback: new DefaultTemplateRenderer(),
});Better Auth merges plugin init() options with user options via defu(userOptions, pluginOptions). This means the plugin's callbacks act as defaults: if you provide your own sendVerificationEmail or sendResetPassword, your callback wins.
The plugin automatically provides defaults for:
| Callback | Better Auth option path |
|---|---|
sendVerificationEmail |
emailVerification.sendVerificationEmail |
sendResetPassword |
emailAndPassword.sendResetPassword |
The plugin exposes pre-configured helpers via email.helpers.*. Each helper is a callback matching the signature its target plugin expects:
| Helper | Plugin | Plugin option |
|---|---|---|
helpers.changeEmail |
core | user.changeEmail.sendChangeEmailVerification |
helpers.deleteAccount |
core | user.deleteUser.sendDeleteAccountVerification |
helpers.magicLink |
magicLink |
sendMagicLink |
helpers.otp |
emailOTP |
sendVerificationOTP |
helpers.invitation |
organization |
sendInvitationEmail |
helpers.twoFactor |
twoFactor |
sendOTP |
The standalone factory functions (betterEmailMagicLink, betterEmailOTP, etc.) are still exported for cases where you need to create helpers with different options.
For every email (both core defaults and helpers), the flow is:
templateRenderer.render(context)produces{ subject, html, text }- Tags are merged:
[...defaultTags, ...perTypeTags, { name: 'type', value: context.type }] onBeforeSend(context, message)is called (returnfalseto skip sending)transport.send(message)delivers the emailonAfterSend(context, message)oronSendError(context, message, error)is called
The EmailContext discriminated union covers 8 email types. Each type has a corresponding exported interface:
| Type | Context interface | Key fields |
|---|---|---|
verification-email |
VerificationEmailContext |
user, url, token |
reset-password |
ResetPasswordContext |
user, url, token |
change-email-verification |
ChangeEmailVerificationContext |
user, newEmail, url, token |
delete-account-verification |
DeleteAccountVerificationContext |
user, url, token |
magic-link |
MagicLinkContext |
email, url, token |
verification-otp |
VerificationOTPContext |
email, otp, otpType |
organization-invitation |
OrganizationInvitationContext |
email, organization, inviter, invitation |
two-factor-otp |
TwoFactorOTPContext |
user, otp |
Two utility types simplify working with email contexts:
-
EmailContextFor<T>extracts the full context interface for a given email type from theEmailContextunion:type EmailContextFor<'verification-email'> // => VerificationEmailContext
-
EmailProps<T>strips thetypediscriminator, giving you just the data fields. Use this to type template props and callback data:type EmailProps<'verification-email'> // => { user: User; url: string; token: string }
MIT License - see LICENSE for details.
Copyright (c) 2026 Nuntly