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.
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 #15 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 #215 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 #326 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 #436 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 #548 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 #658 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 #768 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 #878 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});3839export const auth = betterAuth({40 // Core callbacks provided automatically by the plugin41 emailVerification: {42 sendOnSignUp: true,43 // sendVerificationEmail is injected by default44 },45 emailAndPassword: {46 enabled: true,47 // sendResetPassword is injected by default48 },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 defaults61 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.
Ship emails, not infrastructure
Free plan available. No credit card required.
Start sending free