Send transactional email with Resend, build templates with React Email, preview them locally, and see every email the app sends.
Launch Now sends email through Resend. A single helper, sendEmail(), covers
transactional messages; the sign-in code email is a React Email template that you can
preview locally. This guide lists every email the app sends and shows how to add your own.
Where the code lives
| File | Role |
|---|---|
lib/email.ts | The Resend client (resend) and the sendEmail() helper. |
emails/otp-email.tsx | React Email template for the one-time sign-in code. |
emails/email-styles.tsx | Shared email theme: EmailStyles, defaultColors and the EmailClassNames / EmailColors types. |
lib/auth.ts | Sends the sign-in code and organization invitations (Better Auth callbacks). |
lib/account-actions.ts | Sends the "password set" confirmation. |
lib/newsletter-actions.ts | Sends the newsletter confirmation email and newsletter broadcasts. |
lib/notifications/server.ts | Sends notification emails in batches. |
Setup
Create a Resend API key
Sign up at resend.com, verify the domain you send from, and create an API key.
Set the environment variables
RESEND_API_KEY=re_xxxxxxxxxxxxxxxxxxxxxxxx
# Default sender, e.g. "Pulse <hello@example.com>"
EMAIL_FROM="Pulse <hello@example.com>"| Variable | Required | Used for |
|---|---|---|
RESEND_API_KEY | Yes | Authenticates every call to Resend. |
EMAIL_FROM | Yes | Default sender of sendEmail(). Accepts a display name: "Pulse <hello@example.com>". |
APP_NOREPLY_EMAIL | No | Sender of the password confirmation and notification emails. Falls back to EMAIL_FROM. |
APP_HELP_EMAIL | No | Support address mentioned in the password confirmation email and on the legal pages. |
APP_CONTACT_EMAIL | No | Contact address shown on the legal pages. |
RESEND_AUDIENCE_ID | No | Resend audience for the newsletter. The newsletter is off when it's unset. |
All of them are validated by lib/env.ts. The optional APP_*_EMAIL variables must be plain email
addresses (they're validated with z.email()), so they can't include a display name.
Good to know: .env.example also lists APP_ONBOARDING_EMAIL. It is validated but not used by any
email yet.
EMAIL_FROM (and APP_NOREPLY_EMAIL) before going live, or users won't receive their sign-in
codes.Send an email
sendEmail() takes a recipient, a subject and an HTML string. from is optional and defaults to
EMAIL_FROM. It throws when Resend returns an error, so a failed send fails the calling action.
export const resend = new Resend(env.RESEND_API_KEY)
type SendEmailOptions = {
to: string
subject: string
html: string
from?: string
}
export async function sendEmail({ to, subject, html, from }: SendEmailOptions) {
const { error } = await resend.emails.send({
from: from ?? env.EMAIL_FROM,
to,
subject,
html,
})
if (error) {
console.error("Failed to send email:", error)
throw new Error(error.message)
}
}Call it from server code only:
await sendEmail({
to: user.email,
subject: "Password set successfully",
html: `<p>Your password has been set successfully on ${siteConfig.name}.</p>...`,
from: env.APP_NOREPLY_EMAIL ?? env.EMAIL_FROM,
})For anything beyond emails.send (batches, audiences, broadcasts), use the exported resend client
directly.
lib/auth.ts, lib/newsletter-actions.ts and lib/notifications/server.ts each define a
small escapeHtml() helper for this.Emails the app sends
| Trigger | Where | Template | |
|---|---|---|---|
| Sign-in code | A user signs in or signs up with their email address. | emailOTP plugin in lib/auth.ts | emails/otp-email.tsx |
| Organization invitation | An owner or admin invites a member. | organization plugin in lib/auth.ts | Inline HTML |
| Password set | A user sets a password from the security settings or during two-factor setup. | setPassword in lib/account-actions.ts | Inline HTML |
| Newsletter confirmation | A visitor subscribes from the footer form. | lib/newsletter-actions.ts | Inline HTML |
| Newsletter broadcast | An admin sends a newsletter. | lib/newsletter-actions.ts (Resend broadcasts) | Composed in the admin |
| Notification | A notification is sent and the recipient's email preference allows it. | lib/notifications/server.ts | Inline HTML |
The app has no password reset or email verification link emails: public email/password sign-up is disabled and users sign in with a one-time code or OAuth. See Authentication.
Sign-in code
The Better Auth emailOTP plugin calls sendVerificationOTP. The template is rendered to HTML with
render() from @react-email/render:
emailOTP({
expiresIn: OTP_EXPIRES_IN_SECONDS,
async sendVerificationOTP({ email, otp }) {
const html = await render(
OtpEmail({
verificationCode: otp,
email,
appName: siteConfig.name,
expirationMinutes: OTP_EXPIRES_IN_SECONDS / 60,
})
)
await sendEmail({
to: email,
subject: "Your verification code",
html,
})
},
}),OTP_EXPIRES_IN_SECONDS is 600, so codes are valid for 10 minutes. The app name comes from
lib/config/site-config.ts (Pulse in the demo; rename it for your product).
Organization invitation
sendInvitationEmail builds a link to /accept-invitation/<invitationId> from BETTER_AUTH_URL, with the
organization name, the inviter's name and the expiry date. See Organizations.
Newsletter confirmation
Subscriptions are double opt-in. The confirmation email links to /api/newsletter/confirm with a signed
token that expires after 48 hours. The same address gets at most one confirmation per minute. See
Newsletter.
Notifications
lib/notifications/server.ts emails notifications with resend.batch.send, 100 emails per call. Each email
greets the recipient, shows the title and body, adds an "Open" button when the notification has a link, and
links to /account/notifications to manage preferences. See Notifications.
Templates
Templates live in emails/ and are React components built with @react-email/components.
emails/otp-email.tsx shows the conventions to follow:
- Styling with the React Email
Tailwindcomponent (pixelBasedPreset) and semantic class names:bg-background,bg-card,text-muted-foreground,border-border… <EmailStyles colors={colors} darkMode={darkMode} />in the<Head>injects the CSS for those class names, in light mode and, whendarkModeis true, underprefers-color-scheme: dark.- Props for customization:
logoURL(a URL, or{ light, dark }variants),colors,classNames,darkMode,poweredByandlocalization(partial overrides of every text string). OtpEmail.PreviewPropsprovides sample data for the preview server.
Change the colors
Edit defaultColors in emails/email-styles.tsx to match your brand, or pass colors for one email:
OtpEmail({
verificationCode: otp,
email,
appName: siteConfig.name,
colors: {
light: { primary: "#4F46E5" },
dark: { primary: "#A5B4FC" },
},
})Any color you don't set falls back to defaultColors.
Preview templates
pnpm email:devThis runs email dev from the react-email package, a local preview server for the components in
emails/. It renders each template with its PreviewProps and reloads when you edit them.
Add a template
Create the component
Add a file in emails/, for example emails/welcome-email.tsx. Start from otp-email.tsx: keep the
Html, Head with EmailStyles, Tailwind and Body structure, and replace the content.
Add preview data
WelcomeEmail.PreviewProps = {
name: "Ada",
appName: "Pulse",
} as WelcomeEmailProps
export default WelcomeEmailRun pnpm email:dev to check the result.
Render and send it
import { render } from "@react-email/render"
import { WelcomeEmail } from "@/emails/welcome-email"
import { sendEmail } from "@/lib/email"
const html = await render(WelcomeEmail({ name: user.name, appName: siteConfig.name }))
await sendEmail({ to: user.email, subject: `Welcome to ${siteConfig.name}`, html })The invitation, password and notification emails are plain HTML strings. To give them the same design as
the sign-in code, move them to templates in emails/ and render them the same way.
Use another provider
Only lib/email.ts knows how the password confirmation, invitation and sign-in emails are delivered. To
switch providers, rewrite sendEmail() with the same signature. The newsletter
(lib/newsletter-actions.ts, features/newsletter/newsletter.server.ts) and notification emails
(lib/notifications/server.ts) call the resend client directly and need their own changes.