Email & Transactional Emails
Send emails with React Email and multiple providers - Resend, Postmark, AWS SES, SendGrid, and more
Email & Transactional Emails
ProductReady uses two workspace packages for email:
- emaillib - A polymorphic email library that supports 8+ email providers with a unified API
- transactional - Pre-built React Email templates for common use cases
Why this architecture? Write emails once with React components, switch providers anytime without code changes. The separation allows templates to be shared across all apps in the monorepo.
Notification & Email Triggers
The following table shows all places in ProductReady that trigger notifications and transactional emails:
| Trigger Event | Transactional Email | In-App Notification | Template | Recipient |
|---|---|---|---|---|
| User Signup | ✅ Email Verification | ✅ "Email Verification Sent" | verify-email.tsx | New user |
| Forgot Password | ✅ Password Reset | ✅ "Password Reset Requested" | password-reset.tsx | User requesting reset |
| Organization Invitation | ✅ Invitation Email | ✅ "Invitation Sent" (to inviter) | invitation.tsx | Invitee |
Trigger Locations in Code
| Event | Code Location | Better Auth Hook |
|---|---|---|
| Email Verification | src/lib/auth/email.tsx → sendVerificationEmail() | emailVerification.sendVerificationEmail |
| Password Reset | src/lib/auth/email.tsx → sendResetPasswordEmail() | emailAndPassword.sendResetPassword |
| Organization Invitation | src/lib/auth/email.tsx → sendOrganizationInvitation() | organization.invitations.sendInvitationEmail |
Dual-write pattern: Every transactional email also creates an in-app notification record in the notifications table. This provides users with an audit trail of all emails sent to them, viewable in the dashboard.
Quick Overview
Supported providers:
- ✅ Resend - Modern email API (recommended)
- ✅ Postmark - Reliable transactional email
- ✅ AWS SES - Cost-effective at scale
- ✅ Nodemailer - SMTP support (any provider)
- ✅ SendGrid - Popular email platform
- ✅ MailerSend - Feature-rich email API
- ✅ Scaleway - European cloud email
- ✅ Plunk - Simple transactional email
Key features:
- 🎨 React Email - Build emails with React components
- 🔌 Provider abstraction - Consistent API across all providers
- 🎯 Type-safe - Full TypeScript support
- 🔐 Better Auth integration - Email verification & password reset built-in
- 📦 Shared templates - Reusable across all apps
Project Structure
packages/
├── emaillib/ # Email client abstraction
│ └── src/
│ ├── index.ts # createEmailClient, sendEmail
│ └── types.ts # EmailProviderConfig, SendEmailOptions
│
└── transactional/ # React Email templates
├── emails/
│ ├── _components/ # Shared components (Header, Footer, Button)
│ │ └── styles.ts # Shared inline styles
│ ├── welcome.tsx
│ ├── verify-email.tsx
│ ├── password-reset.tsx
│ ├── invitation.tsx
│ └── notification.tsx
└── package.json # Exports for direct imports
apps/productready/
└── src/lib/
├── email-client.ts # App-specific email configuration
└── auth/
└── email.tsx # Better Auth email hooksInstallation
Both packages are workspace dependencies:
pnpm add emaillib@workspace:* transactional@workspace:*Email Client Configuration
Basic Setup
// src/lib/email-client.ts
import { createEmailClient } from "emaillib";
import type { EmailProviderConfig } from "emaillib/types";
const getEmailConfig = (): EmailProviderConfig => {
const provider = process.env.EMAIL_PROVIDER || "resend";
switch (provider) {
case "resend":
return {
type: "resend",
apiKey: process.env.RESEND_API_KEY || "",
};
case "postmark":
return {
type: "postmark",
serverToken: process.env.POSTMARK_SERVER_TOKEN || "",
};
case "sendgrid":
return {
type: "sendgrid",
apiKey: process.env.SENDGRID_API_KEY || "",
};
case "ses":
return {
type: "ses",
region: process.env.AWS_REGION || "us-east-1",
accessKeyId: process.env.AWS_ACCESS_KEY_ID || "",
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || "",
};
default:
return {
type: "resend",
apiKey: process.env.RESEND_API_KEY || "",
};
}
};
export const emailClient = createEmailClient(getEmailConfig());
export const EMAIL_FROM = process.env.EMAIL_FROM || "ProductReady <noreply@productready.dev>";
export const emailConfig = {
appName: "ProductReady",
homeUrl: process.env.BETTER_AUTH_URL || "https://productready.dev",
logoUrl: process.env.EMAIL_LOGO_URL,
supportEmail: "support@productready.dev",
};Environment Variables
# Email Provider (resend, postmark, sendgrid, ses)
EMAIL_PROVIDER=resend
# Resend
RESEND_API_KEY=re_xxx
# Postmark
POSTMARK_SERVER_TOKEN=xxx
# SendGrid
SENDGRID_API_KEY=SG.xxx
# AWS SES
AWS_REGION=us-east-1
AWS_ACCESS_KEY_ID=xxx
AWS_SECRET_ACCESS_KEY=xxx
# Common
EMAIL_FROM="ProductReady <noreply@productready.dev>"
EMAIL_LOGO_URL=https://productready.dev/logo.png
# Better Auth
EMAIL_VERIFICATION_REQUIRED=trueBetter Auth Integration
ProductReady integrates email sending with Better Auth for:
- Email verification on signup
- Password reset
- Organization invitations
Email Hooks with Notification Records
Every email sent creates a corresponding notification record in the database. This allows users to see their email history in the dashboard, and admins can view user notifications in the System Admin panel.
// src/lib/auth/email.tsx
import { VerifyEmail } from "transactional/verify-email";
import { PasswordResetEmail } from "transactional/password-reset";
import { InvitationEmail } from "transactional/invitation";
import { emailClient, EMAIL_FROM, emailConfig } from "~/lib/email-client";
import {
createEmailVerificationNotification,
createPasswordResetNotification,
createOrganizationInvitationNotification,
} from "~/lib/notifications";
export const sendVerificationEmail = async ({
user,
url,
}: {
user: { email: string; name?: string; id?: string };
url: string;
token?: string;
}) => {
const result = await emailClient.send({
from: EMAIL_FROM,
to: user.email,
subject: `Verify your email for ${emailConfig.appName}`,
react: VerifyEmail({
userName: user.name || "there",
appName: emailConfig.appName,
verifyUrl: url,
logoUrl: emailConfig.logoUrl,
}),
});
if (!result.success) {
console.error("[Auth Email] Failed to send verification email:", result.error);
throw new Error("Failed to send verification email");
}
// Create notification record for the user
if (user.id) {
await createEmailVerificationNotification(user.id, user.email);
}
return result;
};
export const sendResetPasswordEmail = async ({
user,
url,
}: {
user: { email: string; name?: string; id?: string };
url: string;
token?: string;
}) => {
const result = await emailClient.send({
from: EMAIL_FROM,
to: user.email,
subject: `Reset your ${emailConfig.appName} password`,
react: PasswordResetEmail({
userName: user.name || "there",
appName: emailConfig.appName,
resetUrl: url,
logoUrl: emailConfig.logoUrl,
expiresIn: "1 hour",
}),
});
if (!result.success) {
throw new Error("Failed to send reset password email");
}
// Create notification record for the user
if (user.id) {
await createPasswordResetNotification(user.id, user.email);
}
return result;
};
export const sendOrganizationInvitation = async ({
email,
organization,
inviter,
role,
inviteUrl,
}: {
email: string;
organization: { name: string; id: string; logo?: string };
inviter: { name: string; email: string; avatar?: string; id?: string };
role: string;
inviteUrl: string;
}) => {
const result = await emailClient.send({
from: EMAIL_FROM,
to: email,
subject: `${inviter.name} invited you to join ${organization.name}`,
react: InvitationEmail({
inviterName: inviter.name,
inviterEmail: inviter.email,
inviterAvatar: inviter.avatar,
organizationName: organization.name,
organizationLogo: organization.logo,
role,
inviteUrl,
appName: emailConfig.appName,
logoUrl: emailConfig.logoUrl,
}),
});
if (!result.success) {
throw new Error("Failed to send invitation email");
}
// Create notification record for the inviter
if (inviter.id) {
await createOrganizationInvitationNotification(inviter.id, email, organization.name);
}
return result;
};Notification Service
The notification service (src/lib/notifications/index.ts) provides helper functions to create notification records:
// src/lib/notifications/index.ts
import { db } from "~/db";
import { notifications } from "~/db/schema";
interface CreateNotificationParams {
userId: string;
title: string;
message: string;
link?: string;
}
export async function createNotification({
userId,
title,
message,
link,
}: CreateNotificationParams) {
const [notification] = await db
.insert(notifications)
.values({
userId,
title,
message,
link,
read: false,
})
.returning();
return notification;
}
// Pre-built notification creators for common email types
export async function createEmailVerificationNotification(userId: string, userEmail: string) {
return createNotification({
userId,
title: "Email Verification Sent",
message: `A verification email has been sent to ${userEmail}. Please check your inbox.`,
link: "/account",
});
}
export async function createPasswordResetNotification(userId: string, userEmail: string) {
return createNotification({
userId,
title: "Password Reset Requested",
message: `A password reset email has been sent to ${userEmail}. The link will expire in 1 hour.`,
link: "/account",
});
}
export async function createOrganizationInvitationNotification(
userId: string,
inviteeEmail: string,
organizationName: string,
) {
return createNotification({
userId,
title: "Invitation Sent",
message: `An invitation to join ${organizationName} has been sent to ${inviteeEmail}.`,
});
}Why create notification records? This provides an audit trail of all emails sent to users. Users can view their notification history in the dashboard, and system admins can see all notifications for any user in the admin panel under User Details.
Better Auth Configuration
// src/lib/auth/index.ts
import { betterAuth } from "better-auth";
import {
sendVerificationEmail,
sendResetPasswordEmail,
sendOrganizationInvitation,
} from "./email";
export const auth = betterAuth({
// ... other config
emailAndPassword: {
enabled: true,
requireEmailVerification: process.env.EMAIL_VERIFICATION_REQUIRED === "true",
sendResetPassword: async ({ user, url, token }, _request) => {
await sendResetPasswordEmail({ user, url, token });
},
},
emailVerification: {
sendOnSignUp: true,
autoSignInAfterVerification: true,
sendVerificationEmail: async ({ user, url, token }, _request) => {
await sendVerificationEmail({ user, url, token });
},
},
plugins: [
organization({
sendInvitationEmail: async (data) => {
await sendOrganizationInvitation({
email: data.email,
organization: data.organization,
inviter: data.inviter,
role: data.role,
inviteUrl: data.invitationUrl,
});
},
}),
],
});Transactional Email Templates
Available Templates
Import templates directly from the transactional package:
import { WelcomeEmail } from "transactional/welcome";
import { VerifyEmail } from "transactional/verify-email";
import { PasswordResetEmail } from "transactional/password-reset";
import { InvitationEmail } from "transactional/invitation";
import { NotificationEmail } from "transactional/notification";Template Props
interface WelcomeEmailProps {
userName: string;
appName: string;
dashboardUrl?: string;
logoUrl?: string;
}interface VerifyEmailProps {
userName: string;
appName: string;
verifyUrl: string;
logoUrl?: string;
}interface PasswordResetEmailProps {
userName: string;
appName: string;
resetUrl: string;
logoUrl?: string;
expiresIn?: string;
}interface InvitationEmailProps {
inviterName: string;
inviterEmail: string;
inviterAvatar?: string;
organizationName: string;
organizationLogo?: string;
role: string;
inviteUrl: string;
appName: string;
logoUrl?: string;
}interface NotificationEmailProps {
userName: string;
appName: string;
title: string;
message: string;
actionUrl?: string;
actionText?: string;
logoUrl?: string;
}Preview Templates Locally
cd packages/transactional
pnpm devVisit http://localhost:3000 to preview all email templates.
Basic Usage
export function WelcomeEmail({
name,
loginUrl = 'https://productready.com/login',
}: WelcomeEmailProps) {
return (
<Html>
<Head />
<Preview>Welcome to ProductReady - Let's get started!</Preview>
<Body style={main}>
<Container style={container}>
<Img
src="https://productready.com/logo.png"
width="48"
height="48"
alt="ProductReady"
style={logo}
/>
<Heading style={h1}>Welcome to ProductReady, {name}!</Heading>
<Text style={text}>
Thanks for signing up! We're excited to help you ship your AI-powered
SaaS faster.
</Text>
<Section style={buttonContainer}>
<Button style={button} href={loginUrl}>
Get Started
</Button>
</Section>
<Text style={text}>
If you have any questions, feel free to{' '}
<Link href="https://productready.com/support" style={link}>
contact our support team
</Link>
.
</Text>
<Text style={footer}>
© 2025 ProductReady. All rights reserved.
</Text>
</Container>
</Body>
</Html>
);
}
// Styles
const main = {
backgroundColor: '#f6f9fc',
fontFamily: '-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif',
};
const container = {
backgroundColor: '#ffffff',
margin: '0 auto',
padding: '20px 0 48px',
marginBottom: '64px',
};
const logo = {
margin: '0 auto',
};
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',
padding: '0 20px',
};
const button = {
backgroundColor: '#5469d4',
borderRadius: '4px',
color: '#fff',
fontSize: '16px',
textDecoration: 'none',
textAlign: 'center' as const,
display: 'block',
width: '200px',
padding: '12px',
margin: '0 auto',
};
const buttonContainer = {
padding: '27px 0',
};
const link = {
color: '#5469d4',
textDecoration: 'underline',
};
const footer = {
color: '#8898aa',
fontSize: '12px',
lineHeight: '16px',
padding: '0 20px',
marginTop: '32px',
textAlign: 'center' as const,
};Preview Emails Locally
Use React Email's dev server:
# Install React Email CLI
pnpm add -D @react-email/cli
# Add script to package.json
{
"scripts": {
"email:dev": "email dev"
}
}
# Start preview server
pnpm email:devVisit http://localhost:3000 to preview all email templates.
Provider Configuration
Resend (Recommended)
import { createEmailClient } from '@emaillib/core';
const emailClient = createEmailClient({
type: 'resend',
apiKey: process.env.RESEND_API_KEY!, // Get from resend.com
});Environment variables:
RESEND_API_KEY=re_123456789Why Resend?
- ✅ Modern API with great DX
- ✅ React Email built-in support
- ✅ Generous free tier (3,000 emails/month)
- ✅ Fast delivery
Postmark
const emailClient = createEmailClient({
type: 'postmark',
apiKey: process.env.POSTMARK_API_KEY!,
});Environment variables:
POSTMARK_API_KEY=your-postmark-server-tokenAWS SES
const emailClient = createEmailClient({
type: 'aws-ses',
region: 'us-east-1',
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
});Environment variables:
AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE
AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
AWS_REGION=us-east-1Nodemailer (SMTP)
const emailClient = createEmailClient({
type: 'nodemailer',
host: 'smtp.gmail.com',
port: 587,
secure: false,
auth: {
user: process.env.SMTP_USER!,
pass: process.env.SMTP_PASSWORD!,
},
});Environment variables:
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USER=your-email@gmail.com
SMTP_PASSWORD=your-app-passwordSendGrid
const emailClient = createEmailClient({
type: 'sendgrid',
apiKey: process.env.SENDGRID_API_KEY!,
});Common Email Templates
Password Reset
// emails/password-reset.tsx
import { Body, Button, Container, Heading, Html, Text } from '@react-email/components';
interface PasswordResetEmailProps {
resetUrl: string;
expiresIn?: string;
}
export function PasswordResetEmail({
resetUrl,
expiresIn = '1 hour',
}: PasswordResetEmailProps) {
return (
<Html>
<Body>
<Container>
<Heading>Reset Your Password</Heading>
<Text>
We received a request to reset your password. Click the button below
to create a new password.
</Text>
<Button href={resetUrl}>Reset Password</Button>
<Text>
This link will expire in {expiresIn}. If you didn't request a
password reset, you can safely ignore this email.
</Text>
</Container>
</Body>
</Html>
);
}Usage:
import { emailClient } from '~/lib/email';
import { PasswordResetEmail } from '~/emails/password-reset';
await emailClient.send({
from: 'security@productready.com',
to: user.email,
subject: 'Reset Your Password',
react: <PasswordResetEmail resetUrl={`https://productready.com/reset?token=${token}`} />,
});Email Verification
// emails/verify-email.tsx
export function VerifyEmailEmail({ verifyUrl }: { verifyUrl: string }) {
return (
<Html>
<Body>
<Container>
<Heading>Verify Your Email Address</Heading>
<Text>
Thanks for signing up! Please verify your email address by clicking
the button below.
</Text>
<Button href={verifyUrl}>Verify Email</Button>
<Text>
If you didn't create an account, you can safely ignore this email.
</Text>
</Container>
</Body>
</Html>
);
}Invoice/Receipt
// emails/invoice.tsx
interface InvoiceEmailProps {
invoiceNumber: string;
amount: number;
currency: string;
date: Date;
downloadUrl: string;
}
export function InvoiceEmail({
invoiceNumber,
amount,
currency,
date,
downloadUrl,
}: InvoiceEmailProps) {
return (
<Html>
<Body>
<Container>
<Heading>Invoice #{invoiceNumber}</Heading>
<Text>
Thank you for your payment of {currency.toUpperCase()} {amount.toFixed(2)}
on {date.toLocaleDateString()}.
</Text>
<Button href={downloadUrl}>Download Invoice</Button>
</Container>
</Body>
</Html>
);
}Team Invitation
// emails/team-invite.tsx
interface TeamInviteEmailProps {
inviterName: string;
teamName: string;
inviteUrl: string;
}
export function TeamInviteEmail({
inviterName,
teamName,
inviteUrl,
}: TeamInviteEmailProps) {
return (
<Html>
<Body>
<Container>
<Heading>{inviterName} invited you to join {teamName}</Heading>
<Text>
You've been invited to collaborate on {teamName} on ProductReady.
</Text>
<Button href={inviteUrl}>Accept Invitation</Button>
</Container>
</Body>
</Html>
);
}Integration with tRPC
Send emails from tRPC mutations:
// src/server/routers/auth.ts
import { createTRPCRouter, publicProcedure } from '../trpc';
import { emailClient } from '~/lib/email';
import { WelcomeEmail } from '~/emails/welcome';
import { z } from 'zod';
export const authRouter = createTRPCRouter({
signUp: publicProcedure
.input(z.object({
email: z.string().email(),
name: z.string(),
password: z.string().min(8),
}))
.mutation(async ({ input }) => {
// Create user in database
const user = await db.insert(users).values({
email: input.email,
name: input.name,
passwordHash: await hashPassword(input.password),
}).returning();
// Send welcome email
await emailClient.send({
from: 'noreply@productready.com',
to: user.email,
subject: 'Welcome to ProductReady!',
react: <WelcomeEmail name={user.name} />,
});
return { success: true, userId: user.id };
}),
});Advanced Features
Attachments
await emailClient.send({
from: 'noreply@productready.com',
to: 'user@example.com',
subject: 'Your Invoice',
react: <InvoiceEmail />,
attachments: [
{
filename: 'invoice.pdf',
content: pdfBuffer, // Buffer or base64
contentType: 'application/pdf',
},
],
});CC and BCC
await emailClient.send({
from: 'noreply@productready.com',
to: 'user@example.com',
cc: ['manager@productready.com'],
bcc: ['archive@productready.com'],
subject: 'Team Update',
react: <TeamUpdateEmail />,
});Custom Headers
await emailClient.send({
from: 'noreply@productready.com',
to: 'user@example.com',
subject: 'Custom Headers',
react: <CustomEmail />,
headers: {
'X-Custom-Header': 'value',
'X-Priority': '1',
},
});Plain Text Fallback
await emailClient.send({
from: 'noreply@productready.com',
to: 'user@example.com',
subject: 'Welcome',
react: <WelcomeEmail name="John" />,
text: 'Welcome to ProductReady, John!', // Fallback for email clients without HTML support
});Error Handling
Basic Error Handling
const result = await emailClient.send({
from: 'noreply@productready.com',
to: 'user@example.com',
subject: 'Test',
react: <TestEmail />,
});
if (!result.success) {
console.error('Failed to send email:', result.error);
// Handle specific error types
if (result.error.includes('Invalid email')) {
// Handle invalid email
} else if (result.error.includes('Rate limit')) {
// Handle rate limit
}
}Retry Logic
async function sendEmailWithRetry(options: SendEmailOptions, maxRetries = 3) {
for (let i = 0; i < maxRetries; i++) {
const result = await emailClient.send(options);
if (result.success) {
return result;
}
if (i < maxRetries - 1) {
// Wait before retrying (exponential backoff)
await new Promise(resolve => setTimeout(resolve, Math.pow(2, i) * 1000));
}
}
throw new Error('Failed to send email after retries');
}Email Queue (Background Jobs)
For better reliability, send emails in background:
Using Inngest
// src/inngest/functions/send-email.ts
import { inngest } from '~/inngest/client';
import { emailClient } from '~/lib/email';
export const sendEmailJob = inngest.createFunction(
{ id: 'send-email' },
{ event: 'email.send' },
async ({ event }) => {
await emailClient.send({
from: event.data.from,
to: event.data.to,
subject: event.data.subject,
html: event.data.html,
});
}
);Trigger from tRPC:
import { inngest } from '~/inngest/client';
// In your mutation
await inngest.send({
name: 'email.send',
data: {
from: 'noreply@productready.com',
to: user.email,
subject: 'Welcome!',
html: await renderEmail(<WelcomeEmail name={user.name} />),
},
});Testing
Test in Development
Use a test provider to avoid sending real emails:
// src/lib/email.ts
import { createEmailClient } from '@emaillib/core';
export const emailClient = createEmailClient(
process.env.NODE_ENV === 'production'
? {
type: 'resend',
apiKey: process.env.RESEND_API_KEY!,
}
: {
type: 'nodemailer',
host: 'localhost',
port: 1025, // MailHog or similar
secure: false,
}
);Unit Tests
// src/server/routers/auth.test.ts
import { vi } from 'vitest';
import { emailClient } from '~/lib/email';
vi.mock('~/lib/email', () => ({
emailClient: {
send: vi.fn().mockResolvedValue({ success: true, messageId: 'test-id' }),
},
}));
test('sends welcome email on signup', async () => {
const result = await caller.auth.signUp({
email: 'test@example.com',
name: 'Test User',
password: 'password123',
});
expect(emailClient.send).toHaveBeenCalledWith(
expect.objectContaining({
to: 'test@example.com',
subject: 'Welcome to ProductReady!',
})
);
});Best Practices
✅ Do's
- Use React Email for maintainable templates
- Test emails in development before production
- Handle errors gracefully with retries
- Use background jobs for non-critical emails
- Include plain text fallback
- Personalize with user data
- Add unsubscribe links for marketing emails
- Track email delivery and open rates
❌ Don'ts
- Don't send from no-reply if you expect replies
- Don't spam - respect user preferences
- Don't hardcode email content (use templates)
- Don't forget to test on mobile devices
- Don't send without user consent
- Don't use images for critical content
Email Deliverability Tips
Improve Delivery Rates
-
Set up SPF, DKIM, and DMARC
- Add DNS records for your domain
- Verify domain ownership with provider
-
Use a dedicated sending domain
mail.productready.cominstead ofproductready.com- Protects your main domain reputation
-
Warm up your domain
- Start with low volume
- Gradually increase over weeks
-
Monitor bounce rates
- Remove invalid emails
- Keep bounce rate < 2%
-
Provide unsubscribe
- Make it easy to opt-out
- Honor unsubscribe requests immediately
Migration Between Providers
Switch providers without code changes:
// src/lib/email.ts
import { createEmailClient } from '@emaillib/core';
// Before: Resend
const emailClient = createEmailClient({
type: 'resend',
apiKey: process.env.RESEND_API_KEY!,
});
// After: Postmark (just change config!)
const emailClient = createEmailClient({
type: 'postmark',
apiKey: process.env.POSTMARK_API_KEY!,
});All email templates and sending code remain unchanged!
Next Steps
- Set up provider - Choose and configure an email provider
- Create templates - Build email templates with React Email
- Integrate tRPC - Send emails from your API
- Test thoroughly - Preview and test in development
- Monitor delivery - Track email performance
Start with Resend for the best developer experience. It's free for up to 3,000 emails/month and has excellent documentation.