DEV Community

Cover image for How to Setup Email Verification & Organization Invites with Better Auth and Nodemailer
rogasper
rogasper

Posted on • Originally published at rogasper.com

How to Setup Email Verification & Organization Invites with Better Auth and Nodemailer

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.

Step 1: Installation

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/nodemailer
Enter fullscreen mode Exit fullscreen mode

Step 2: Setting Up the Email Transporter

Before touching the auth logic, we need a helper file to handle the actual sending of emails. We will create a transporter (the postman) and defining our HTML templates (the letters).
Create 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 transporter
Enter fullscreen mode Exit fullscreen mode

Step 3: Configurating the Better Auth Server

Now, we hook everything into Better Auth. We will use two plugins:

  1. emailOTP: Handles the logic for generating and validating one-time passwords.
  2. 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
    }),
  ]
});
Enter fullscreen mode Exit fullscreen mode

Step 4: Environment Variables

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:3000
Enter fullscreen mode Exit fullscreen mode

Step 5: Frontend Implementation (Client Side)

Now that the backend is ready, here is how you trigger these actions from your frontend (React Example).

Sign Up (Triggering Verification)

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
};
Enter fullscreen mode Exit fullscreen mode

Sending an Invitation

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
};
Enter fullscreen mode Exit fullscreen mode

Accepting an Invitation

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>;
}
Enter fullscreen mode Exit fullscreen mode

Why This Architecture Works

  1. Flexibility: By using Nodemailer, you aren't locked into a specific provider. You can start with Gmail for free, then switch to AWS SES or SendGrid as you scale, without changing your auth logic.
  2. Full Control: You have 100% control over the HTML templates. You can brand them, style them, and adjust the wording exactly how you want.
  3. Security: Your credentials stay on the server. The Client simply request an action (like "invite user"), and the secure server handles the email delivery. Integrating email verification and invites makes your app feel professional and secure. With Better Auth and Nodemailer, it's surprisingly easy to implement.

for more information you can check my website

Top comments (0)