Blog

Stay up to date with the latest blog posts and articles.

Open SourceBetter AuthDeveloper ExperienceTypeScriptAuthentication

Better Auth emails made simple with Better Email

Enhance Better Auth with centralized email management. Better Email gives you one provider, one renderer, consistent error handling, and lifecycle hooks across all 8 auth email types.

Olivier Bazoud
2/14/2026
5 min read

Better Email brings unified email management to Better Auth. Instead of configuring emails in 8+ places, centralize everything: one provider, one renderer, lifecycle hooks, and type-safe templates.

If you use Better Auth, you know the problem: email logic is scattered everywhere. Verification emails, password resets, magic links, two-factor codes, organization invitations. Each one lives in a different place, each needs its own configuration, and changing your email provider means hunting down 8+ spots in your auth.ts file.

One unified interface for all Better Auth emails

Better Auth is excellent at authentication. Its plugin system makes it flexible. Better Email extends that flexibility by providing a unified interface for all email operations.

Here's what a typical Better Auth setup looks like when you wire up all the email features:

1export const auth = betterAuth({
2 emailVerification: {
3 sendVerificationEmail: async ({ user, url, token }) => {
4 // Custom email logic #1
5 await yourEmailApi.send({
6 to: user.email,
7 subject: 'Verify your email',
8 html: `<p>Click here: ${url}</p>`,
9 });
10 },
11 },
12 emailAndPassword: {
13 sendResetPassword: async ({ user, url, token }) => {
14 // Custom email logic #2
15 await yourEmailApi.send({
16 to: user.email,
17 subject: 'Reset password',
18 html: `<p>Reset here: ${url}</p>`,
19 });
20 },
21 },
22 user: {
23 changeEmail: {
24 sendChangeEmailVerification: async ({ user, newEmail, url, token }) => {
25 // Custom email logic #3
26 await yourEmailApi.send({
27 to: newEmail,
28 subject: 'Confirm email change',
29 html: `<p>Confirm: ${url}</p>`,
30 });
31 },
32 },
33 deleteUser: {
34 sendDeleteAccountVerification: async ({ user, url, token }) => {
35 // Custom email logic #4
36 await yourEmailApi.send({
37 to: user.email,
38 subject: 'Delete account',
39 html: `<p>Confirm deletion: ${url}</p>`,
40 });
41 },
42 },
43 },
44 plugins: [
45 magicLink({
46 sendMagicLink: async ({ email, url, token }) => {
47 // Custom email logic #5
48 await yourEmailApi.send({
49 to: email,
50 subject: 'Sign in link',
51 html: `<p>Sign in: ${url}</p>`,
52 });
53 },
54 }),
55 emailOTP({
56 sendVerificationOTP: async ({ email, otp }) => {
57 // Custom email logic #6
58 await yourEmailApi.send({
59 to: email,
60 subject: 'Verification code',
61 html: `<p>Your code: ${otp}</p>`,
62 });
63 },
64 }),
65 organization({
66 sendInvitationEmail: async ({ email, organization, inviter, invitation }) => {
67 // Custom email logic #7
68 await yourEmailApi.send({
69 to: email,
70 subject: `Invitation to ${organization.name}`,
71 html: `<p>Join: ${invitation.url}</p>`,
72 });
73 },
74 }),
75 twoFactor({
76 sendOTP: async ({ user, otp }) => {
77 // Custom email logic #8
78 await yourEmailApi.send({
79 to: user.email,
80 subject: 'Two-factor code',
81 html: `<p>Code: ${otp}</p>`,
82 });
83 },
84 }),
85 ],
86});
87

Every callback repeats the same pattern: build the message, call the API, handle errors. And when you need to change providers or switch from plain HTML to React Email? You rewrite all 8 callbacks.

Better Email decouples what you send from how you send it

Better Email introduces two independent interfaces:

  • Provider: handles delivery only (Nuntly, SES, Resend, Postmark, Mailgun, SMTP, Console)
  • Renderer: handles HTML/text generation only (plain HTML, React MJML, React Email, MJML, Mustache)

Swap providers without touching templates. Switch renderers without changing provider config. No tight coupling.

Here's the same auth setup with Better Email:

1const email = betterEmail({
2 provider: new NuntlyProvider({
3 apiKey: process.env.NUNTLY_API_KEY!,
4 from: 'auth@yourapp.com',
5 }),
6 templateRenderer: new ReactEmailRenderer({
7 render,
8 createElement,
9 templates: {
10 'verification-email': VerificationEmail,
11 'reset-password': ResetPasswordEmail,
12 'magic-link': MagicLinkEmail,
13 'verification-otp': OTPEmail,
14 'organization-invitation': InvitationEmail,
15 'change-email-verification': ChangeEmailEmail,
16 'delete-account-verification': DeleteAccountEmail,
17 'two-factor-otp': TwoFactorOTPEmail,
18 },
19 subjects: {
20 'verification-email': 'Verify your email',
21 'reset-password': 'Reset your password',
22 'magic-link': 'Your sign-in link',
23 'verification-otp': 'Your verification code',
24 'organization-invitation': (ctx) => `Join ${ctx.organization.name}`,
25 'change-email-verification': 'Confirm email change',
26 'delete-account-verification': 'Confirm account deletion',
27 'two-factor-otp': 'Your two-factor code',
28 },
29 }),
30 defaultTags: [{ name: 'app', value: 'yourapp' }],
31 onAfterSend: async (ctx, msg) => {
32 console.log(`Sent ${ctx.type} to ${msg.to}`);
33 },
34 onSendError: async (ctx, msg, err) => {
35 console.error(`Failed ${ctx.type}:`, err);
36 },
37});
38
39export const auth = betterAuth({
40 // Core callbacks provided automatically by the plugin
41 emailVerification: {
42 sendOnSignUp: true,
43 // sendVerificationEmail is injected by default
44 },
45 emailAndPassword: {
46 enabled: true,
47 // sendResetPassword is injected by default
48 },
49 user: {
50 changeEmail: {
51 enabled: true,
52 sendChangeEmailVerification: email.helpers.changeEmail,
53 },
54 deleteUser: {
55 enabled: true,
56 sendDeleteAccountVerification: email.helpers.deleteAccount,
57 },
58 },
59 plugins: [
60 email, // Injects core defaults
61 twoFactor({ sendOTP: email.helpers.twoFactor }),
62 organization({ sendInvitationEmail: email.helpers.invitation }),
63 magicLink({ sendMagicLink: email.helpers.magicLink }),
64 emailOTP({ sendVerificationOTP: email.helpers.otp }),
65 ],
66});
67

One provider. One renderer. Eight email types configured. Error handling and lifecycle hooks applied consistently everywhere.

What makes Better Email different

Separation of concerns:Providers handle delivery. Renderers handle templates. Switch providers without touching templates. Switch renderers without changing provider config.

Production-ready options:7 providers (Nuntly, SES, Resend, Postmark, Mailgun, SMTP, Console) and 5 renderers (Default HTML, React Email, MJML, Mustache, React MJML). Or implement the EmailProvider or EmailTemplateRenderer interface for your own.

Type-safe templates:Every email type has a typed context. The EmailProps<T> utility extracts the correct fields from the discriminated union. TypeScript catches missing fields at compile time.

Lifecycle hooks:Apply the same logging, tracking, and error handling across all email types with onBeforeSend, onAfterSend, and onSendError.

Tag management:Add default tags to every email, plus per-type tags for analytics and segmentation. Every email automatically gets { name: 'type', value: ctx.type } added for filtering.

Open source and available now

Better Email is open source and available at github.com/nuntly/better-email.

Check the README for full setup instructions, or browse the demo app to see React Email, MJML, and Mustache templates side by side.

Better Email is open source and we love contributors. If you want to add a feature, fix a bug, or improve documentation, head over to GitHub. Open an issue to discuss your idea or submit a pull request. All contributions are welcome.

Ready to get started?

Ship emails, not infrastructure

Free plan available. No credit card required.

Start sending free