Every SaaS project needs the same handful of emails. Welcome message, email verification, password reset, a receipt after a Stripe payment. I used to reach for whatever email service a client already had configured. Now I default to Resend paired with React Email, since writing the email template as an actual React component instead of a giant string of HTML changed how much I dread this part of a project.
Here is the setup.
1. The Resend Client
// lib/email.ts
import { Resend } from 'resend';
export const resend = new Resend(process.env.RESEND_API_KEY as string);
One client, imported wherever an email needs to go out.
2. Writing Email Templates as React Components
This is the part that makes Resend worth using over a plain HTML string. React Email renders actual JSX into email-safe HTML, handling the inconsistencies between email clients for you.
npm install react-email @react-email/components
// emails/VerifyEmail.tsx
import {
Html,
Head,
Body,
Container,
Heading,
Text,
Button,
} from '@react-email/components';
interface VerifyEmailProps {
name: string;
verifyUrl: string;
}
export function VerifyEmail({ name, verifyUrl }: VerifyEmailProps) {
return (
<Html>
<Head />
<Body style={{ fontFamily: 'sans-serif', backgroundColor: '#0f0f0f' }}>
<Container style={{ padding: '32px', color: '#e5e5e5' }}>
<Heading style={{ fontSize: '20px' }}>Verify your email</Heading>
<Text>Hi {name}, confirm your email to activate your account.</Text>
<Button
href={verifyUrl}
style={{
backgroundColor: '#3b82f6',
color: '#fff',
padding: '12px 20px',
borderRadius: '8px',
}}
>
Verify email
</Button>
</Container>
</Body>
</Html>
);
}
This is a normal React component. Props in, styled JSX out, the same mental model as any other component you write, instead of maintaining a separate HTML template with manually inlined styles.
3. Sending the Email from a Server Action
// actions/auth.ts
'use server';
import { resend } from '@/lib/email';
import { VerifyEmail } from '@/emails/VerifyEmail';
import { signVerificationToken } from '@/lib/auth';
export async function sendVerificationEmail(userId: string, name: string, email: string) {
const token = signVerificationToken(userId);
const verifyUrl = `${process.env.NEXT_PUBLIC_URL}/verify?token=${token}`;
await resend.emails.send({
from: 'Pixel Anas <noreply@pixelanas.com>',
to: email,
subject: 'Verify your email',
react: VerifyEmail({ name, verifyUrl }),
});
}
Passing the component directly as react here means Resend handles rendering it to HTML internally, no separate render step needed on your side.
4. Verifying the Domain Before Sending
Emails sent from an unverified domain either land in spam or get rejected outright by most providers. Before any of this works in production, the sending domain needs SPF, DKIM, and DMARC records added through the Resend dashboard.
# Example DNS records Resend asks you to add
TXT @ "v=spf1 include:resend.com ~all"
CNAME resend._domainkey resend.domainkey.resend.com
This step is easy to skip during local development, since test emails often go through fine anyway, and then quietly breaks the first time real users hit the signup flow in production.
5. Password Reset Flow
// emails/ResetPassword.tsx
import { Html, Body, Container, Heading, Text, Button } from '@react-email/components';
export function ResetPasswordEmail({ resetUrl }: { resetUrl: string }) {
return (
<Html>
<Body style={{ fontFamily: 'sans-serif' }}>
<Container style={{ padding: '32px' }}>
<Heading>Reset your password</Heading>
<Text>This link expires in 1 hour. If you did not request this, ignore this email.</Text>
<Button href={resetUrl}>Reset password</Button>
</Container>
</Body>
</Html>
);
}
// actions/password.ts
'use server';
import { resend } from '@/lib/email';
import { ResetPasswordEmail } from '@/emails/ResetPassword';
import { connectDB } from '@/lib/db';
import User from '@/models/User';
import crypto from 'crypto';
export async function requestPasswordReset(email: string) {
await connectDB();
const user = await User.findOne({ email });
// Always return success, whether or not the email exists
if (!user) return { success: true };
const token = crypto.randomBytes(32).toString('hex');
const expires = new Date(Date.now() + 60 * 60 * 1000);
await User.findByIdAndUpdate(user._id, {
resetToken: token,
resetTokenExpires: expires,
});
const resetUrl = `${process.env.NEXT_PUBLIC_URL}/reset-password?token=${token}`;
await resend.emails.send({
from: 'Pixel Anas <noreply@pixelanas.com>',
to: email,
subject: 'Reset your password',
react: ResetPasswordEmail({ resetUrl }),
});
return { success: true };
}
Returning the same success response whether or not the email exists in the database matters here. Returning a different message for "email not found" tells an attacker which emails are registered accounts, which is a real information leak on a password reset flow.
6. Sending a Receipt After a Stripe Payment
This connects directly to the webhook handler from the Stripe integration. Trigger the email from inside the webhook, not from the client redirect, for the same reliability reason webhooks matter for updating subscription status.
// app/api/webhooks/stripe/route.ts
case 'checkout.session.completed': {
const session = event.data.object as Stripe.Checkout.Session;
const userId = session.metadata?.userId;
await User.findByIdAndUpdate(userId, {
subscriptionStatus: 'active',
});
const user = await User.findById(userId);
if (user) {
await resend.emails.send({
from: 'Pixel Anas <billing@pixelanas.com>',
to: user.email,
subject: 'Payment confirmed',
react: ReceiptEmail({ name: user.name, amount: session.amount_total }),
});
}
break;
}
Same principle as before, if the receipt only sent from the browser redirect, a closed tab means the customer never gets confirmation their payment went through, even though it actually did.
7. Previewing Emails Locally Without Sending Them
React Email includes a local preview server so you can see exactly how a template renders before it goes anywhere.
// package.json
{
"scripts": {
"email:dev": "email dev"
}
}
npm run email:dev
This opens a local preview at localhost:3000 (a separate dev server from your Next.js app) showing every template in the emails/ folder rendered live, with hot reload as you edit. Far faster than sending a real email to yourself every time you tweak a line of copy.
Summary
| Pattern | Handles |
|---------|---------|
| React Email components | Writing templates as JSX instead of raw HTML strings |
| resend.emails.send({ react: ... }) | Sending without a separate render step |
| Domain verification (SPF, DKIM, DMARC) | Emails actually landing in the inbox instead of spam |
| Same response regardless of account existence | Preventing email enumeration on password reset |
| Sending from the webhook, not the redirect | Reliable delivery even if the browser tab closes early |
| Local preview server | Fast iteration on copy and design without sending real emails |
The email side of a SaaS project used to be the part I put off longest. Treating templates as normal React components, and triggering sends from webhooks instead of client redirects, made it feel like the rest of the stack instead of a separate, annoying system bolted on.
I use this exact setup, Resend plus React Email, triggered from Server Actions and webhooks, across every SaaS project with auth or billing.
Get the templates: https://pixelanas.gumroad.com
What do you use for transactional email in your projects? Drop it below 👇
Anas, full-stack Next.js developer building SaaS products and premium templates. X: @ASheikh69751