
Authentication today goes beyond just "username and password." To build a secure and collaborative application, you almost cretainly need two things: Email Verification (to ensure users are real) and Organization Invites (to let users collaborate).
Better Auth is fantastic because it's modular. Instead of forcing a specific email provider on you, it provides the logic and lets you handle the delivery.
In this guide, we will implement OTP Verification and Team Invitations using Better Auth and Nodemailer. We chose Nodemailer because it gives you total freedom, you can use Gmail, AWS SES, Resend, or any SMTP server you want.
First, let's get the necessary packages installed. We need the core auth library and the mailer transport.
npm install nodemailer better-auth
npm install -D @types/nodemailerBefore touching the auth logic, we need a helper file to handle the actual sending of emails. We will create a
transporterCreate a file at src/lib/email.ts:
// src/lib/email.ts
import nodemailer from 'nodemailer'
// 1. Setup Transporter
const smtpPort = Number(process.env.HOST_PORT) || 587;
const transporter = nodemailer.createTransport({
host: process.env.HOST_EMAIL,
port: smtpPort,
secure: smtpPort === 465, // true for 465, false for other ports
auth: {
user: process.env.HOST_AUTH_USER,
pass: process.env.HOST_AUTH_PASS,
},
});
// 2. OTP Verification Template
type VerificationProps = {
email: string,
otp: string,
type: "sign-in" | "email-verification" | "forget-password";
}
export const verificationEmailTemplate = ({ email, otp, type }: VerificationProps) => {
return {
from: `"My App" <${process.env.HOST_ALIAS_EMAIL}>`,
to: email,
subject: "Your Verification Code",
html: `
<div style="font-family: sans-serif; padding: 20px;">
<h2>Verification Code</h2>
<p>Please use the following code to complete your action:</p>
<div style="background: #f3f4f6; padding: 20px; text-align: center; font-size: 32px; font-weight: bold; letter-spacing: 5px;">
${otp}
</div>
<p>This code expires in 5 minutes.</p>
</div>
`,
}
}
// 3. Organization Invitation Template
type InvitationProps = {
email: string;
inviterName: string;
inviterEmail: string;
organizationName: string;
inviteLink: string;
role: string;
}
export const invitationEmailTemplate = (data: InvitationProps) => {
return {
from: `"My App" <${process.env.HOST_ALIAS_EMAIL}>`,
to: data.email,
subject: `You've been invited to ${data.organizationName}`,
html: `
<div style="font-family: sans-serif; padding: 20px;">
<h2>You're Invited!</h2>
<p><strong>${data.inviterName}</strong> has invited you to join <strong>${data.organizationName}</strong>.</p>
<br />
<a href="${data.inviteLink}" style="background: #007bff; color: white; padding: 12px 24px; text-decoration: none; border-radius: 5px; font-weight: bold;">
Accept Invitation
</a>
<p style="margin-top: 20px; font-size: 12px; color: #666;">Or copy this link: ${data.inviteLink}</p>
</div>
`,
}
}
export default transporterNow, we hook everything into Better Auth. We will use two plugins:
emailOTP: Handles the logic for generating and validating one-time passwords.organization: Handles team management and intivations tokens.In your main config file (usually src/lib/auth.ts), inject the Nodemailer functions:
// src/lib/auth.ts
import { betterAuth } from "better-auth";
import { organization, emailOTP } from "better-auth/plugins";
import transporter, { verificationEmailTemplate, invitationEmailTemplate } from "./email";
// ... other imports (db, adapters, etc)
export const auth = betterAuth({
// ... your database and adapter config here
plugins: [
// 1. Organization Plugin (For Invites)
organization({
async sendInvitationEmail(data) {
// Construct the acceptance link (frontend route)
const inviteLink = `${process.env.VITE_APP_URL}/accept-invitation/${data.id}`;
// Send the email
await transporter.sendMail(invitationEmailTemplate({
email: data.email,
inviterName: data.inviter.user.name || "A user",
inviterEmail: data.inviter.user.email,
organizationName: data.organization.name,
inviteLink,
role: data.role,
}));
},
}),
// 2. Email OTP Plugin (For Verification)
emailOTP({
async sendVerificationOTP({ email, otp, type }) {
// Send the OTP
await transporter.sendMail(verificationEmailTemplate({ email, otp, type }));
},
otpLength: 6,
expiresIn: 300, // 5 minutes
}),
]
});Security is key. Never hardcode your SMTP credentials. Set these up in your .env file.
Pro Tip: For local development, use a service like Mailtrap or Ethereal to catch emails without spamming real users.
# SMTP Configuration
HOST_EMAIL=smtp.gmail.com
HOST_PORT=587
HOST_AUTH_USER=your-email@gmail.com
HOST_AUTH_PASS=your-app-password
HOST_ALIAS_EMAIL=no-reply@yourdomain.com
# Frontend URL
VITE_APP_URL=http://localhost:3000Now that the backend is ready, here is how you trigger these actions from your frontend (React Example).
When a user signs up using the emailOTP method, Better Auth will automatically trigger the sendVerificationOTP function we defined earlier.
import { authClient } from "@/lib/auth-client";
const handleSignUp = async () => {
await authClient.signUp.email({
email: "newuser@example.com",
password: "securepassword",
name: "John Doe",
callbackURL: "/dashboard"
});
// Redirect user to an OTP input page
};This is usually done by an Admin inside their dashboard settings.
import { authClient } from "@/lib/auth-client";
const inviteMember = async () => {
await authClient.organization.inviteMember({
email: "colleague@example.com",
role: "member", // or 'admin', 'owner'
});
// This triggers the 'sendInvitationEmail' function on the server
};When the user clicks the link in their email (e.g., http://localhost:3000/accept-invitation/invitation-id-123), you handle it on this page:
// On your /accept-invitation/[id] page
import { useParams } from "react-router-dom"; // or your router of choice
import { authClient } from "@/lib/auth-client";
const AcceptPage = () => {
const { id } = useParams();
const handleAccept = async () => {
const { error } = await authClient.organization.acceptInvitation({
invitationId: id
});
if (!error) {
// Redirect to organization dashboard
}
};
return <button onClick={handleAccept}>Join Organization</button>;
}Integrating email verification and invites makes your app feel professional and secure. With Better Auth and Nodemailer, it's surprisingly easy to implement.