Every SaaS needs the same six emails before launch day: welcome, email verification, password reset, trial ending, payment receipt, and failed payment recovery. Skip any one of them and you get support tickets, churn, or both.
This guide walks through building each one with React Email and Resend in a Next.js app. For each template, you will see the setup code, the edge cases that eat your time, and exactly how long it takes to build from scratch versus dropping in a production-ready template.
| When It Sends | What Breaks If You Skip It | |
|---|---|---|
| Welcome | Immediately after signup | Users forget your product exists within 24 hours |
| Email verification | After signup or email change | Fake accounts, deliverability damage from bad addresses |
| Password reset | User-initiated | Support tickets, account lockouts, users leave |
| Trial ending | 3 days and 1 day before expiry | Users churn without ever seeing the upgrade prompt |
| Payment receipt | After successful charge | Refund requests, compliance issues, lost trust |
| Failed payment | After charge failure | Silent involuntary churn - your biggest revenue leak |
The six emails every SaaS needs before launch
Project Setup: React Email + Resend
Before building templates, set up the shared infrastructure. This takes about 10 minutes and every template reuses the same pattern.
npm install @react-email/components resend
npm install -D react-emailimport { Resend } from "resend";
export const resend = new Resend(process.env.RESEND_API_KEY);
// Shared send helper with error handling
export async function sendEmail({
to,
subject,
react,
}: {
to: string;
subject: string;
react: React.ReactElement;
}) {
const { data, error } = await resend.emails.send({
from: "YourApp <hello@yourapp.com>",
to,
subject,
react,
});
if (error) {
console.error("Email send failed:", error);
throw new Error(error.message);
}
return data;
}from address once in a shared helper. Every template inherits it. When you rebrand or change domains, you update one file instead of six.Building Each Template
Welcome email
The welcome email is your first impression. It needs your brand logo, a clear next-action CTA, and a layout that renders correctly across Gmail, Outlook, and Apple Mail. Sounds simple until you discover Outlook ignores max-width, Gmail strips <style> blocks, and Yahoo wraps your single-column layout into two columns on mobile.
import {
Html, Head, Body, Container, Section,
Text, Button, Img, Preview,
} from "@react-email/components";
interface WelcomeEmailProps {
name: string;
loginUrl: string;
}
export function WelcomeEmail({ name, loginUrl }: WelcomeEmailProps) {
return (
<Html>
<Head />
<Preview>Welcome to YourApp, {name}</Preview>
<Body style={{ backgroundColor: "#f9fafb", fontFamily: "sans-serif" }}>
<Container style={{ maxWidth: "560px", margin: "0 auto", padding: "40px 20px" }}>
<Img
src="https://yourapp.com/logo.png"
width={120}
height={40}
alt="YourApp"
/>
<Section style={{ marginTop: "32px" }}>
<Text style={{ fontSize: "24px", fontWeight: "bold" }}>
Welcome, {name}
</Text>
<Text style={{ color: "#6b7280", lineHeight: "1.6" }}>
Your account is ready. Here is what to do next:
</Text>
<Button
href={loginUrl}
style={{
backgroundColor: "#000",
color: "#fff",
padding: "12px 24px",
borderRadius: "6px",
fontSize: "14px",
textDecoration: "none",
}}
>
Open your dashboard
</Button>
</Section>
</Container>
</Body>
</Html>
);
}This works in a demo. In production, you will spend the next 4-6 hours handling: dark mode color inversions, Outlook conditional comments for the button (mso- padding hacks), a text-only fallback, proper <!DOCTYPE> declarations, and responsive font sizes for mobile. A production-ready welcome template ships with all of these solved and tested across 90+ email clients.
Email verification
Verification emails have a unique constraint: the token link must work exactly once, expire after a set window (typically 24 hours), and the email must reach the inbox fast enough that the user does not abandon the flow. If this email lands in spam, your signup funnel is broken.
import {
Html, Head, Body, Container, Section,
Text, Button, Preview, Hr,
} from "@react-email/components";
interface VerifyEmailProps {
verifyUrl: string;
expiresIn: string;
}
export function VerifyEmail({ verifyUrl, expiresIn }: VerifyEmailProps) {
return (
<Html>
<Head />
<Preview>Verify your email address</Preview>
<Body style={{ backgroundColor: "#f9fafb", fontFamily: "sans-serif" }}>
<Container style={{ maxWidth: "560px", margin: "0 auto", padding: "40px 20px" }}>
<Text style={{ fontSize: "20px", fontWeight: "bold" }}>
Confirm your email
</Text>
<Text style={{ color: "#6b7280", lineHeight: "1.6" }}>
Click the button below to verify your email address.
This link expires in {expiresIn}.
</Text>
<Button
href={verifyUrl}
style={{
backgroundColor: "#000",
color: "#fff",
padding: "12px 24px",
borderRadius: "6px",
}}
>
Verify email
</Button>
<Hr style={{ margin: "32px 0", borderColor: "#e5e7eb" }} />
<Text style={{ fontSize: "12px", color: "#9ca3af" }}>
If you did not create an account, ignore this email.
</Text>
</Container>
</Body>
</Html>
);
}The hidden complexity: the raw URL must be visible as plain text below the button (some corporate email clients block buttons entirely), the "ignore this email" disclaimer is legally required in many jurisdictions, and the expire timestamp should adjust to the recipient's timezone. The email verification template handles all of this, including a fallback link, proper security copy, and accessible color contrast ratios.
Password reset
Password reset emails carry more weight than any other transactional email. They are a security boundary. If the email looks even slightly off - wrong logo, broken layout, suspicious link formatting - users assume it is phishing and file a support ticket instead of clicking.
import {
Html, Head, Body, Container, Section,
Text, Button, Preview, Hr,
} from "@react-email/components";
interface PasswordResetProps {
resetUrl: string;
ipAddress: string;
userAgent: string;
}
export function PasswordResetEmail({
resetUrl,
ipAddress,
userAgent,
}: PasswordResetProps) {
return (
<Html>
<Head />
<Preview>Reset your password</Preview>
<Body style={{ backgroundColor: "#f9fafb", fontFamily: "sans-serif" }}>
<Container style={{ maxWidth: "560px", margin: "0 auto", padding: "40px 20px" }}>
<Text style={{ fontSize: "20px", fontWeight: "bold" }}>
Password reset request
</Text>
<Text style={{ color: "#6b7280", lineHeight: "1.6" }}>
Someone requested a password reset for your account.
If this was you, click the button below.
</Text>
<Button
href={resetUrl}
style={{
backgroundColor: "#000",
color: "#fff",
padding: "12px 24px",
borderRadius: "6px",
}}
>
Reset password
</Button>
<Hr style={{ margin: "32px 0", borderColor: "#e5e7eb" }} />
<Text style={{ fontSize: "12px", color: "#9ca3af" }}>
Request from {ipAddress} using {userAgent}.
If you did not request this, you can safely ignore this email.
</Text>
</Container>
</Body>
</Html>
);
}Password reset emails need security metadata (IP, user agent, timestamp), a plaintext URL fallback, an expiration warning, and rock-solid rendering so the user trusts the email enough to click. Building this properly takes 6-8 hours. The password reset template includes security context blocks, branded header, accessible button styles, and dark mode support.
Trial ending reminder
Trial ending emails are conversion emails disguised as notifications. The goal is not just to inform - it is to convert the user to a paid plan before the trial expires. You need two: one at 3 days out (nudge) and one at 1 day out (urgency).
import {
Html, Head, Body, Container, Section,
Text, Button, Preview,
} from "@react-email/components";
interface TrialEndingProps {
name: string;
daysLeft: number;
upgradeUrl: string;
planName: string;
price: string;
}
export function TrialEndingEmail({
name,
daysLeft,
upgradeUrl,
planName,
price,
}: TrialEndingProps) {
return (
<Html>
<Head />
<Preview>
Your trial ends in {daysLeft} {daysLeft === 1 ? "day" : "days"}
</Preview>
<Body style={{ backgroundColor: "#f9fafb", fontFamily: "sans-serif" }}>
<Container style={{ maxWidth: "560px", margin: "0 auto", padding: "40px 20px" }}>
<Text style={{ fontSize: "20px", fontWeight: "bold" }}>
{daysLeft === 1
? "Your trial ends tomorrow"
: `Your trial ends in ${daysLeft} days`}
</Text>
<Text style={{ color: "#6b7280", lineHeight: "1.6" }}>
Hi {name}, your free trial is wrapping up.
Upgrade to {planName} ({price}/mo) to keep everything
you have built.
</Text>
<Button
href={upgradeUrl}
style={{
backgroundColor: "#000",
color: "#fff",
padding: "12px 24px",
borderRadius: "6px",
}}
>
Upgrade now
</Button>
</Container>
</Body>
</Html>
);
}The DIY version above is a notification. A conversion-optimized version includes a feature usage summary (showing what the user would lose), social proof, a pricing comparison, and urgency indicators that change based on daysLeft. Building those conditional layouts and testing them across clients takes a full day. The trial ending template includes all of these conversion elements, tested and ready to customize with your brand and pricing.
Payment receipt
Payment receipts are legally required in many regions and customers expect them instantly. The template needs to handle line items, tax calculations, currency formatting, billing address display, and a link to the invoice PDF.
import {
Html, Head, Body, Container, Section,
Text, Row, Column, Preview, Hr,
} from "@react-email/components";
interface LineItem {
description: string;
amount: string;
}
interface PaymentReceiptProps {
customerName: string;
invoiceNumber: string;
date: string;
lineItems: LineItem[];
total: string;
invoiceUrl: string;
}
export function PaymentReceiptEmail({
customerName,
invoiceNumber,
lineItems,
total,
invoiceUrl,
}: PaymentReceiptProps) {
return (
<Html>
<Head />
<Preview>Payment receipt #{invoiceNumber}</Preview>
<Body style={{ backgroundColor: "#f9fafb", fontFamily: "sans-serif" }}>
<Container style={{ maxWidth: "560px", margin: "0 auto", padding: "40px 20px" }}>
<Text style={{ fontSize: "20px", fontWeight: "bold" }}>
Payment received
</Text>
<Text style={{ color: "#6b7280" }}>
Thanks, {customerName}. Here is your receipt.
</Text>
<Section style={{
border: "1px solid #e5e7eb",
borderRadius: "8px",
padding: "16px",
marginTop: "24px",
}}>
{lineItems.map((item, i) => (
<Row key={i}>
<Column>
<Text style={{ margin: "4px 0" }}>{item.description}</Text>
</Column>
<Column align="right">
<Text style={{ margin: "4px 0" }}>{item.amount}</Text>
</Column>
</Row>
))}
<Hr style={{ borderColor: "#e5e7eb" }} />
<Row>
<Column>
<Text style={{ fontWeight: "bold" }}>Total</Text>
</Column>
<Column align="right">
<Text style={{ fontWeight: "bold" }}>{total}</Text>
</Column>
</Row>
</Section>
</Container>
</Body>
</Html>
);
}The table layout above will break in Outlook, which does not support CSS border-radius or flexbox. Production receipts need <table>-based layouts with mso- conditional comments, proper currency localization, and a PDF download link. The invoice email template uses battle-tested table layouts that render identically in Outlook 2016 through Outlook 365.
Failed payment recovery
Failed payment emails recover revenue. The average SaaS loses 9% of MRR to involuntary churn from failed charges. A good dunning email needs a direct "update payment method" CTA, context on what the user will lose, and a tone that is helpful, not threatening.
import {
Html, Head, Body, Container,
Text, Button, Preview,
} from "@react-email/components";
interface FailedPaymentProps {
name: string;
amount: string;
updateUrl: string;
retryDate: string;
}
export function FailedPaymentEmail({
name,
amount,
updateUrl,
retryDate,
}: FailedPaymentProps) {
return (
<Html>
<Head />
<Preview>Action required: payment of {amount} failed</Preview>
<Body style={{ backgroundColor: "#f9fafb", fontFamily: "sans-serif" }}>
<Container style={{ maxWidth: "560px", margin: "0 auto", padding: "40px 20px" }}>
<Text style={{ fontSize: "20px", fontWeight: "bold" }}>
Your payment did not go through
</Text>
<Text style={{ color: "#6b7280", lineHeight: "1.6" }}>
Hi {name}, we could not process your payment of {amount}.
Please update your payment method before {retryDate} to
avoid any interruption to your account.
</Text>
<Button
href={updateUrl}
style={{
backgroundColor: "#000",
color: "#fff",
padding: "12px 24px",
borderRadius: "6px",
}}
>
Update payment method
</Button>
</Container>
</Body>
</Html>
);
}Effective dunning emails escalate over a sequence: gentle reminder on day 1, feature-loss warning on day 3, final notice on day 7. Each email in the sequence needs different copy, different urgency, and different CTAs. Building and testing a 3-email dunning sequence from scratch takes 1-2 days. The failed payment template includes the complete recovery sequence with escalating urgency and Stripe Billing Portal integration.
The Real Time Cost
Build vs. buy: the math
Each template above takes 4-8 hours to build, test across email clients, handle dark mode, fix Outlook rendering, add mobile responsiveness, and write accessible markup. Six templates at 6 hours each is 36 hours of email work - nearly a full work week spent on emails instead of your product.
With production-ready templates, the same setup takes an afternoon. You customize colors, drop in your logo, wire up the send triggers, and ship. The rendering bugs, client-specific hacks, and dark mode edge cases are already solved.
Wiring Templates to Your App
Once you have your templates (built or bought), the integration pattern is the same for all six. Here is a complete Next.js API route that handles the welcome email:
import { sendEmail } from "@/lib/email";
import { WelcomeEmail } from "@/emails/welcome";
import { db } from "@/lib/db";
export async function POST(req: Request) {
const { email, name, password } = await req.json();
// Create user in your database
const user = await db.user.create({
data: { email, name, password: await hash(password) },
});
// Send welcome email - non-blocking
sendEmail({
to: email,
subject: `Welcome to YourApp, ${name}`,
react: <WelcomeEmail name={name} loginUrl="https://yourapp.com/login" />,
}).catch((err) => {
// Log but don't block signup if email fails
console.error("Welcome email failed:", err);
});
return Response.json({ userId: user.id });
}await so the API response is not blocked by email delivery. Log failures separately. Users should never see a signup error because the email provider had a timeout.from address, build (or buy) your welcome, verification, password reset, trial ending, receipt, and failed payment templates, then wire each one as a fire-and-forget call in your API routes. Test every template in Outlook, Gmail, Apple Mail, and at least one mobile client. Handle dark mode with forced color inversion testing, not just prefers-color-scheme. The difference between building from scratch and using production-ready templates is about 36 hours of rendering bugs, client-specific hacks, and edge cases you discover one at a time.