Better Auth
Complete guide to integrating Unsent with Better Auth for seamless authentication emails
Better Auth is a powerful, framework-agnostic authentication library for TypeScript. This guide shows you how to integrate Unsent with Better Auth to send beautiful, reliable authentication emails including magic links, email verifications, and password resets.
Prerequisites
Node.js 16+: Better Auth requires Node.js 16 or higher
Unsent API Key: Generate one in your Unsent dashboard
Verified Domain: Set up a domain in the Domains section
Database: Better Auth supports PostgreSQL, MySQL, SQLite, and more
Installation
Install Better Auth, Unsent SDK, and any necessary adapters:
npm install better-auth @unsent/sdk
npm install better-auth-adapter-drizzle # or your preferred adapterpnpm add better-auth @unsent/sdk
pnpm add better-auth-adapter-drizzle # or your preferred adapteryarn add better-auth @unsent/sdk
yarn add better-auth-adapter-drizzle # or your preferred adapterbun add better-auth @unsent/sdk
bun add better-auth-adapter-drizzle # or your preferred adapterConfiguration
Environment Variables
Add your credentials to .env.local:
# Unsent Configuration
UNSENT_API_KEY=un_...
FROM_EMAIL=noreply@yourdomain.com
# Better Auth Configuration
BETTER_AUTH_URL=http://localhost:3000
BETTER_AUTH_SECRET=your-secret-key
# Database
DATABASE_URL=postgresql://user:password@localhost:5432/mydbNever commit your .env.local file to version control. Add it to .gitignore if not already present.
Email Helper Function
Create a helper function to send emails with Unsent:
import { unsent } from "@unsent/sdk";
const client = new unsent(
process.env.UNSENT_API_KEY,
process.env.NODE_ENV === "development" ? "http://localhost:3004" : undefined
);
export async function sendEmail(
email: string,
subject: string,
text: string,
html: string
) {
const { data, error } = await client.emails.send({
to: email,
from: process.env.FROM_EMAIL!,
subject,
text,
html,
});
if (error) {
console.error("Failed to send email:", error);
throw new Error(`Failed to send email: ${JSON.stringify(error)}`);
}
return data;
}Magic Link Authentication
Better Auth's magic link plugin allows passwordless authentication. Here's how to integrate it with Unsent:
Basic Setup
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { magicLink } from "better-auth/plugins";
import { db } from "./db";
import { sendEmail } from "./email";
export const auth = betterAuth({
database: drizzleAdapter(db, {
provider: "pg",
}),
plugins: [
magicLink({
sendMagicLink: async ({ email, token, url }, _request) => {
const verificationUrl = `${process.env.BETTER_AUTH_URL}/auth/verify?token=${token}`;
const subject = "Sign in to Your App";
const text = `Hello,\n\nSign in to your account by clicking the link below:\n${verificationUrl}\n\nOr use this one-time code: ${token}\n\nIf you didn't request this, you can safely ignore this email.\n\nBest regards,\nYour Team`;
const html = `
<h2>Sign in to Your App</h2>
<p>Click the button below to sign in to your account:</p>
<a href="${verificationUrl}" style="display: inline-block; padding: 12px 24px; background-color: #0070f3; color: white; text-decoration: none; border-radius: 6px;">Sign In</a>
<p>Or copy and paste this link:</p>
<p>${verificationUrl}</p>
<p>Or use this one-time code: <strong>${token.toUpperCase()}</strong></p>
<p>If you didn't request this, you can safely ignore this email.</p>
`;
await sendEmail(email, subject, text, html);
},
}),
],
baseURL: process.env.BETTER_AUTH_URL,
trustedOrigins: [process.env.BETTER_AUTH_URL!],
});With React Email Templates
For beautiful, maintainable email templates, integrate with React Email:
Install React Email
npm install @react-email/components @react-email/renderpnpm add @react-email/components @react-email/renderyarn add @react-email/components @react-email/renderbun add @react-email/components @react-email/renderCreate Email Template
import {
Body,
Button,
Container,
Head,
Heading,
Html,
Link,
Preview,
Section,
Text,
} from "@react-email/components";
interface MagicLinkEmailProps {
otpCode: string;
loginUrl: string;
hostName: string;
}
export function MagicLinkEmail({
otpCode,
loginUrl,
hostName,
}: MagicLinkEmailProps) {
return (
<Html>
<Head />
<Preview>Sign in to {hostName}</Preview>
<Body style={main}>
<Container style={container}>
<Heading style={h1}>Sign in to {hostName}</Heading>
<Text style={text}>
Click the button below to sign in to your account:
</Text>
<Section style={buttonContainer}>
<Button style={button} href={loginUrl}>
Sign In
</Button>
</Section>
<Text style={text}>
Or copy and paste this link into your browser:
</Text>
<Link href={loginUrl} style={link}>
{loginUrl}
</Link>
<Section style={codeContainer}>
<Text style={codeText}>Or use this one-time code:</Text>
<Text style={code}>{otpCode}</Text>
</Section>
<Text style={footer}>
If you didn't request this email, you can safely ignore it.
</Text>
</Container>
</Body>
</Html>
);
}
// Styles
const main = {
backgroundColor: "#f6f9fc",
fontFamily: '-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Ubuntu,sans-serif',
};
const container = {
backgroundColor: "#ffffff",
margin: "0 auto",
padding: "20px 0 48px",
marginBottom: "64px",
};
const h1 = {
color: "#333",
fontSize: "24px",
fontWeight: "bold",
margin: "40px 0",
padding: "0",
textAlign: "center" as const,
};
const text = {
color: "#333",
fontSize: "16px",
lineHeight: "26px",
textAlign: "center" as const,
};
const buttonContainer = {
textAlign: "center" as const,
margin: "32px 0",
};
const button = {
backgroundColor: "#0070f3",
borderRadius: "6px",
color: "#fff",
fontSize: "16px",
fontWeight: "bold",
textDecoration: "none",
textAlign: "center" as const,
display: "block",
padding: "12px 24px",
};
const link = {
color: "#0070f3",
fontSize: "14px",
textAlign: "center" as const,
display: "block",
margin: "16px 0",
};
const codeContainer = {
background: "#f4f4f4",
borderRadius: "4px",
margin: "32px auto",
padding: "24px",
width: "fit-content",
};
const codeText = {
fontSize: "14px",
textAlign: "center" as const,
margin: "0 0 8px 0",
};
const code = {
color: "#333",
fontSize: "32px",
fontWeight: "bold",
letterSpacing: "4px",
textAlign: "center" as const,
margin: "0",
};
const footer = {
color: "#8898aa",
fontSize: "12px",
lineHeight: "16px",
textAlign: "center" as const,
marginTop: "32px",
};
export default MagicLinkEmail;Render and Send Template
import { render } from "@react-email/render";
import { MagicLinkEmail } from "./magic-link";
export async function renderMagicLinkEmail(props: {
otpCode: string;
loginUrl: string;
hostName: string;
}): Promise<string> {
return await render(<MagicLinkEmail {...props} />);
}Update your auth configuration:
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { magicLink } from "better-auth/plugins";
import { db } from "./db";
import { renderMagicLinkEmail } from "../emails";
import { sendEmail } from "./email";
export const auth = betterAuth({
// ... database config
plugins: [
magicLink({
sendMagicLink: async ({ email, token, url }, _request) => {
const { host } = new URL(url);
const verificationUrl = `${process.env.BETTER_AUTH_URL}/auth/verify?token=${token}`;
const html = await renderMagicLinkEmail({
otpCode: token.toUpperCase(),
loginUrl: verificationUrl,
hostName: host,
});
const text = `Hello,\n\nSign in to your account by clicking the link below:\n${verificationUrl}\n\nOr use this one-time code: ${token}\n\nBest regards,\nYour Team`;
await sendEmail(email, "Sign in to Your App", text, html);
},
}),
],
// ... other config
});React Email provides a development preview server. Run npx react-email dev to preview your email templates while developing.
Social Providers with Email Notifications
Configure social login providers and send welcome emails to new users:
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { magicLink } from "better-auth/plugins";
import { db } from "./db";
import { sendEmail } from "./email";
export const auth = betterAuth({
database: drizzleAdapter(db, {
provider: "pg",
}),
socialProviders: {
github: {
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
},
google: {
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
},
},
databaseHooks: {
user: {
create: {
async after(user) {
// Send welcome email to new users
if (user.email) {
const subject = "Welcome to Your App!";
const text = `Hi ${user.name || "there"},\n\nWelcome to Your App! We're excited to have you on board.\n\nBest regards,\nYour Team`;
const html = `
<h2>Welcome to Your App!</h2>
<p>Hi ${user.name || "there"},</p>
<p>We're excited to have you on board! Here's what you can do next:</p>
<ul>
<li>Complete your profile</li>
<li>Explore our features</li>
<li>Connect with other users</li>
</ul>
<p>If you have any questions, feel free to reach out.</p>
<p>Best regards,<br>Your Team</p>
`;
try {
await sendEmail(user.email, subject, text, html);
} catch (error) {
console.error("Failed to send welcome email:", error);
}
}
},
},
},
},
plugins: [
magicLink({
sendMagicLink: async ({ email, token, url }, _request) => {
// ... magic link implementation
},
}),
],
baseURL: process.env.BETTER_AUTH_URL,
trustedOrigins: [process.env.BETTER_AUTH_URL!],
});Team Invitations
Send team invitation emails when users are invited to join a team:
import { sendEmail } from "./email";
export async function sendTeamInviteEmail(
email: string,
teamName: string,
inviteUrl: string
) {
const subject = `You've been invited to join ${teamName}`;
const text = `Hello,\n\nYou've been invited to join the "${teamName}" team.\n\nAccept the invitation by clicking the link below:\n${inviteUrl}\n\nIf you weren't expecting this invitation, you can ignore this email.\n\nBest regards,\nYour Team`;
const html = `
<h2>Team Invitation</h2>
<p>You've been invited to join the <strong>${teamName}</strong> team.</p>
<a href="${inviteUrl}" style="display: inline-block; padding: 12px 24px; background-color: #0070f3; color: white; text-decoration: none; border-radius: 6px;">Accept Invitation</a>
<p>Or copy and paste this link:</p>
<p>${inviteUrl}</p>
<p>If you weren't expecting this invitation, you can ignore this email.</p>
`;
await sendEmail(email, subject, text, html);
}Use it in your team invitation flow:
import { auth } from "@/lib/auth";
import { sendTeamInviteEmail } from "@/lib/send-team-invite";
export async function POST(request: Request) {
const session = await auth.api.getSession({ headers: request.headers });
if (!session) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
const { email, teamName } = await request.json();
// Create invitation in database
// const invite = await db.insert(teamInvites).values({ ... });
const inviteUrl = `${process.env.BETTER_AUTH_URL}/teams/accept?token=...`;
await sendTeamInviteEmail(email, teamName, inviteUrl);
return Response.json({ success: true });
}Advanced Configuration
Custom Session with Email Notifications
Extend session data and send notifications for important events:
import { betterAuth } from "better-auth";
import { customSession } from "better-auth/plugins";
import { sendEmail } from "./email";
export const auth = betterAuth({
// ... other config
plugins: [
customSession(async ({ user, session }, _ctx) => {
// Send security alert for new login
if (user.email) {
try {
await sendEmail(
user.email,
"New login to your account",
`A new login was detected on your account.\n\nIf this wasn't you, please secure your account immediately.`,
`<p>A new login was detected on your account.</p><p>If this wasn't you, please <a href="${process.env.BETTER_AUTH_URL}/security">secure your account</a> immediately.</p>`
);
} catch (error) {
console.error("Failed to send security alert:", error);
}
}
return {
user: {
...user,
isAdmin: user.email === process.env.ADMIN_EMAIL,
},
session,
};
}),
],
});Be cautious about sending emails on every session creation as it can be very frequent. Consider implementing rate limiting or only sending alerts for suspicious activity.
Error Handling
Implement robust error handling for email sending:
import { unsent } from "@unsent/sdk";
const client = new unsent(process.env.UNSENT_API_KEY);
export async function sendEmail(
email: string,
subject: string,
text: string,
html: string,
options?: {
replyTo?: string;
fromOverride?: string;
}
) {
if (!process.env.UNSENT_API_KEY) {
throw new Error("UNSENT_API_KEY is not configured");
}
if (!process.env.FROM_EMAIL && !options?.fromOverride) {
throw new Error("FROM_EMAIL is not configured");
}
try {
const { data, error } = await client.emails.send({
to: email,
from: options?.fromOverride ?? process.env.FROM_EMAIL!,
subject,
text,
html,
replyTo: options?.replyTo,
});
if (error) {
console.error("Unsent API error:", error);
throw new Error(`Failed to send email: ${error.message || JSON.stringify(error)}`);
}
console.log(`Email sent successfully to ${email}. ID: ${data?.emailId}`);
return data;
} catch (error) {
console.error("Unexpected error sending email:", error);
throw error;
}
}Complete Example
Here's a complete Better Auth configuration with all the email integrations:
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { nextCookies } from "better-auth/next-js";
import { customSession, magicLink } from "better-auth/plugins";
import { db } from "./db";
import { renderMagicLinkEmail } from "../emails";
import { sendEmail } from "./email";
export const auth = betterAuth({
database: drizzleAdapter(db, {
provider: "pg",
}),
socialProviders: {
github: {
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
},
discord: {
clientId: process.env.DISCORD_CLIENT_ID!,
clientSecret: process.env.DISCORD_CLIENT_SECRET!,
},
},
databaseHooks: {
user: {
create: {
async after(user) {
// Send welcome email
if (user.email) {
try {
const subject = "Welcome to Your App!";
const text = `Hi ${user.name || "there"},\n\nWelcome to Your App!`;
const html = `<h2>Welcome!</h2><p>Hi ${user.name || "there"},</p><p>Thanks for joining us!</p>`;
await sendEmail(user.email, subject, text, html);
} catch (error) {
console.error("Failed to send welcome email:", error);
}
}
},
},
},
},
plugins: [
magicLink({
sendMagicLink: async ({ email, token, url }, _request) => {
const { host } = new URL(url);
const verificationUrl = `${process.env.BETTER_AUTH_URL}/auth/verify?token=${token}`;
const html = await renderMagicLinkEmail({
otpCode: token.toUpperCase(),
loginUrl: verificationUrl,
hostName: host,
});
const text = `Sign in to ${host} using this link: ${verificationUrl}\n\nOr use code: ${token}`;
await sendEmail(email, `Sign in to ${host}`, text, html);
},
}),
customSession(async ({ user, session }, _ctx) => {
return {
user: {
...user,
isAdmin: user.email === process.env.ADMIN_EMAIL,
},
session,
};
}),
nextCookies(),
],
baseURL: process.env.BETTER_AUTH_URL,
trustedOrigins: [process.env.BETTER_AUTH_URL!],
});Best Practices
Environment-Specific Configuration
Use different email endpoints for development and production:
import { unsent } from "@unsent/sdk";
export const getEmailClient = () => {
const apiKey = process.env.UNSENT_API_KEY;
const baseUrl = process.env.NODE_ENV === "development"
? "http://localhost:3004"
: undefined;
return new unsent(apiKey, baseUrl);
};Email Rate Limiting
Prevent abuse by implementing rate limiting:
const emailRateLimits = new Map<string, number[]>();
export function checkEmailRateLimit(email: string, maxPerHour = 5): boolean {
const now = Date.now();
const hourAgo = now - 60 * 60 * 1000;
const timestamps = emailRateLimits.get(email) || [];
const recentTimestamps = timestamps.filter(t => t > hourAgo);
if (recentTimestamps.length >= maxPerHour) {
return false;
}
recentTimestamps.push(now);
emailRateLimits.set(email, recentTimestamps);
return true;
}Use in your email sending:
if (!checkEmailRateLimit(email)) {
throw new Error("Rate limit exceeded. Please try again later.");
}
await sendEmail(email, subject, text, html);Testing
Create a mock email client for testing:
import { sendEmail } from "./email";
// Mock Unsent client for tests
jest.mock("@unsent/sdk", () => ({
unsent: jest.fn().mockImplementation(() => ({
emails: {
send: jest.fn().mockResolvedValue({
data: { emailId: "test_123" },
error: null,
}),
},
})),
}));
describe("sendEmail", () => {
it("should send email successfully", async () => {
const result = await sendEmail(
"test@example.com",
"Test Subject",
"Test text",
"<p>Test HTML</p>"
);
expect(result).toBeDefined();
expect(result?.emailId).toBe("test_123");
});
});Logging and Monitoring
Add comprehensive logging for debugging:
import { unsent } from "@unsent/sdk";
export async function sendEmail(
email: string,
subject: string,
text: string,
html: string
) {
const startTime = Date.now();
try {
console.log(`[Email] Sending to ${email}: ${subject}`);
const { data, error } = await client.emails.send({
to: email,
from: process.env.FROM_EMAIL!,
subject,
text,
html,
});
if (error) {
console.error(`[Email] Failed to send to ${email}:`, error);
throw new Error(`Failed to send email: ${JSON.stringify(error)}`);
}
const duration = Date.now() - startTime;
console.log(`[Email] Sent successfully to ${email} in ${duration}ms. ID: ${data?.emailId}`);
return data;
} catch (error) {
console.error(`[Email] Unexpected error:`, error);
throw error;
}
}Next Steps
- React Email Guide: Learn how to create beautiful email templates
- TypeScript SDK: Explore all SDK features
- Better Auth Documentation: Deep dive into Better Auth features
- Webhooks: Track email events like opens and clicks
Check out the Better Auth examples for more integration patterns and use cases.