Adding Transactional Email With Claude Code (Resend + React Email)
How to build welcome, receipt, and password reset emails in a Next.js 16 app using Resend and React Email, with Claude Code writing the templates and the send logic.
Arrête de tout configurer. Place à la construction.
Des templates SaaS avec orchestration IA.
Every SaaS needs three emails on day one: welcome, receipt, password reset. Getting them wrong (landing in spam, missing a DKIM record, silently failing on a typo) costs you users before they even finish onboarding. This is a complete walkthrough for wiring up Resend and React Email in a Next.js 16 app with Claude Code, from domain verification to a working send in a Server Action.
Arrête de tout configurer. Place à la construction.
Des templates SaaS avec orchestration IA.
Why Transactional Email Is Fiddly
Transactional email looks simple until you actually ship it. You need a verified sending domain or your emails go to spam, or don't send at all. You need HTML that renders consistently across Gmail, Outlook, and Apple Mail, which have wildly different CSS support. You need to trigger the send at the right moment (after signup completes, after a payment succeeds) without blocking the response the user is waiting on. And you need to know when a send actually failed instead of assuming it worked.
Resend handles delivery and domain reputation. React Email handles the templates, so you write JSX instead of hand-coded table layouts. Claude Code is useful here because it already knows both APIs well and can wire the plumbing (Server Actions, error handling, environment variables) correctly on the first pass, as long as you tell it exactly what to build.
Installing Resend and React Email
You need three packages: the Resend SDK to send mail, the React Email component library to build templates, and the React Email CLI to preview them locally. Ask Claude Code to install all three and it will use your project's package manager automatically.
npm install resend @react-email/components
npm install -D react-emailresend is the client that talks to the Resend API. @react-email/components gives you pre-built, email-safe primitives (Html, Body, Container, Button, Text) that render correctly across mail clients without you fighting inline CSS. react-email is a dev-only dependency, it powers the local preview server you'll use later.
Getting an API Key and Verifying Your Sending Domain
Sign up at resend.com and create an API key from the dashboard. Store it as an environment variable, never hardcode it in a template or commit it.
# .env.local
RESEND_API_KEY=re_xxxxxxxxxxxxxxxxxxxx
EMAIL_FROM="Your App <hello@yourdomain.com>"Without a verified domain, Resend only lets you send to your own account email using the shared onboarding@resend.dev address. That's fine for a first test, but real users won't receive anything from it. To send to actual inboxes, go to Domains in the Resend dashboard, add your domain, and add the SPF and DKIM records it gives you to your DNS provider. Verification usually takes a few minutes once the records propagate, occasionally longer depending on your registrar.
Tell Claude Code what domain you're using and it will remind you to check verification status before testing a real send:
claude "add a startup check that warns in the console if EMAIL_FROM's domain isn't verified in Resend, using the domains.list API"Building a React Email Template
Templates live in their own folder so the preview server can find them and so they stay separate from your app's UI components. Each template is a plain React component built from @react-email/components primitives, which map to email-safe HTML under the hood.
// emails/welcome-email.tsx
import {
Body,
Button,
Container,
Head,
Heading,
Html,
Preview,
Section,
Text,
} from "@react-email/components";
interface WelcomeEmailProps {
name: string;
dashboardUrl: string;
}
export default function WelcomeEmail({ name, dashboardUrl }: WelcomeEmailProps) {
return (
<Html>
<Head />
<Preview>Your account is ready</Preview>
<Body style={{ backgroundColor: "#f6f6f6", fontFamily: "sans-serif" }}>
<Container style={{ backgroundColor: "#ffffff", padding: "32px", borderRadius: "8px" }}>
<Heading style={{ fontSize: "20px" }}>Welcome, {name}</Heading>
<Text style={{ color: "#444", fontSize: "15px", lineHeight: "22px" }}>
Your account is set up and ready to go. Click below to get started.
</Text>
<Section style={{ marginTop: "24px" }}>
<Button
href={dashboardUrl}
style={{
backgroundColor: "#111",
color: "#fff",
padding: "12px 20px",
borderRadius: "6px",
fontSize: "14px",
}}
>
Go to dashboard
</Button>
</Section>
</Container>
</Body>
</Html>
);
}Inline styles are intentional here, not a shortcut. Most mail clients strip <style> blocks or class-based CSS, so @react-email/components renders your styles inline into the final HTML automatically. You write normal-looking JSX and it comes out email-safe.
A lib/email Helper
Every route that sends mail needs the same Resend client, so it belongs in one shared helper instead of being re-instantiated everywhere. This also gives you one place to add logging or a fallback later.
// lib/email.ts
import { Resend } from "resend";
import type { ReactElement } from "react";
const resend = new Resend(process.env.RESEND_API_KEY);
interface SendEmailParams {
to: string;
subject: string;
react: ReactElement;
}
export async function sendEmail({ to, subject, react }: SendEmailParams) {
const { data, error } = await resend.emails.send({
from: process.env.EMAIL_FROM!,
to,
subject,
react,
});
if (error) {
console.error("Email send failed:", error);
return { success: false as const, error };
}
return { success: true as const, id: data?.id };
}The Resend SDK does not throw on a failed send, it returns an error field on the response object. If you skip that check, a bounced or rejected email looks exactly like a successful one in your logs. The helper above forces every caller to handle both cases explicitly.
Sending From a Server Action (Welcome Email After Signup)
The welcome email should fire right after account creation, without making the user wait on the email provider's response before they land on the app. Wrap the send in the Server Action that runs after signup completes.
// app/(auth)/signup/actions.ts
"use server";
import { createUser } from "@/lib/db";
import { sendEmail } from "@/lib/email";
import WelcomeEmail from "@/emails/welcome-email";
import { redirect } from "next/navigation";
export async function signUp(formData: FormData) {
const email = formData.get("email") as string;
const name = formData.get("name") as string;
const password = formData.get("password") as string;
const user = await createUser({ email, name, password });
const result = await sendEmail({
to: user.email,
subject: "Welcome aboard",
react: WelcomeEmail({
name: user.name,
dashboardUrl: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard`,
}),
});
if (!result.success) {
console.error(`Welcome email failed for user ${user.id}`, result.error);
}
redirect("/dashboard");
}A failed welcome email should never block account creation. The user already has an account at that point, so the code logs the failure and continues to the dashboard instead of throwing. That log line matters, ask Claude Code to wire it into your existing error tracker if you have one, so a spike in email failures actually gets noticed instead of sitting quietly in server logs.
Sending From a Route Handler (Password Reset)
Password reset needs a token generated ahead of the send, and it's often triggered from a form that expects a JSON response rather than a redirect, so a Route Handler fits better than a Server Action here.
// app/api/auth/reset-password/route.ts
import { NextRequest, NextResponse } from "next/server";
import { createResetToken, getUserByEmail } from "@/lib/db";
import { sendEmail } from "@/lib/email";
import ResetPasswordEmail from "@/emails/reset-password-email";
export async function POST(request: NextRequest) {
const { email } = await request.json();
const user = await getUserByEmail(email);
// Always return success, even if the user doesn't exist.
// This avoids leaking which emails have accounts.
if (!user) {
return NextResponse.json({ success: true });
}
const token = await createResetToken(user.id);
const resetUrl = `${process.env.NEXT_PUBLIC_APP_URL}/reset-password/${token}`;
const result = await sendEmail({
to: user.email,
subject: "Reset your password",
react: ResetPasswordEmail({ resetUrl }),
});
if (!result.success) {
return NextResponse.json({ success: false, error: "Failed to send email" }, { status: 500 });
}
return NextResponse.json({ success: true });
}Returning the same success response whether or not the account exists is a small but real security detail. Ask Claude Code for this pattern explicitly ("don't leak account existence through the response") because a naive implementation returns a 404 for unknown emails, which is exactly the information you don't want to hand out.
Building the Receipt Email
Receipts follow the same pattern as the welcome email, just with different content and, usually, a Stripe webhook as the trigger instead of a form submission.
// emails/receipt-email.tsx
import { Body, Container, Head, Heading, Hr, Html, Row, Column, Section, Text } from "@react-email/components";
interface ReceiptEmailProps {
amount: string;
planName: string;
invoiceDate: string;
}
export default function ReceiptEmail({ amount, planName, invoiceDate }: ReceiptEmailProps) {
return (
<Html>
<Head />
<Body style={{ backgroundColor: "#f6f6f6", fontFamily: "sans-serif" }}>
<Container style={{ backgroundColor: "#ffffff", padding: "32px", borderRadius: "8px" }}>
<Heading style={{ fontSize: "20px" }}>Payment received</Heading>
<Section style={{ marginTop: "16px" }}>
<Row>
<Column><Text>Plan</Text></Column>
<Column><Text>{planName}</Text></Column>
</Row>
<Row>
<Column><Text>Amount</Text></Column>
<Column><Text>{amount}</Text></Column>
</Row>
<Row>
<Column><Text>Date</Text></Column>
<Column><Text>{invoiceDate}</Text></Column>
</Row>
</Section>
<Hr />
<Text style={{ color: "#888", fontSize: "12px" }}>
Questions about this charge? Reply to this email.
</Text>
</Container>
</Body>
</Html>
);
}Call this from your Stripe webhook handler after a checkout.session.completed or invoice.paid event, using the same sendEmail helper from earlier. Stripe retries failed webhooks, so if your email send throws inside that handler, wrap it so a bad send doesn't cause Stripe to think the whole webhook failed and retry the entire event.
Previewing Templates Locally
You don't want to send a real email every time you tweak a font size. The React Email CLI spins up a local server that renders your templates live and reloads on save, no API key or network call required.
npx react-email devThis opens a preview at localhost:3000 (or the next free port) showing every component in your emails/ folder, rendered as it would appear in an inbox, with a toggle to inspect the raw HTML output. Point it at a custom folder with --dir if your templates don't live in the default location. Ask Claude Code to pass mock props for each template so the preview shows real-looking content instead of empty fields.
Handling Errors and Idempotency
Two failure modes matter in production. First, a slow or failed call to Resend inside a Server Action can leave a request hanging if you don't set a reasonable expectation for how long you'll wait. Second, retries (a user double-clicking submit, a webhook firing twice) can trigger duplicate sends if nothing stops them.
For duplicate protection, pass an idempotency key on sends triggered by external events like webhooks, so a retried webhook doesn't send the same receipt twice.
await resend.emails.send(
{
from: process.env.EMAIL_FROM!,
to: user.email,
subject: "Payment received",
react: ReceiptEmail({ amount, planName, invoiceDate }),
},
{ idempotencyKey: `receipt-${invoiceId}` }
);Using the Stripe invoice ID as the idempotency key means Resend recognizes a retried request with the same key and skips sending it again, instead of firing a second receipt. This matters most on webhook-triggered sends, where retries are expected behavior, not an edge case.
Testing Before You Ship
Before wiring this into signup or checkout, send a real test email to your own inbox and check three things by hand: the subject line isn't truncated, the button actually links to the right URL, and the email doesn't land in spam. Gmail and Outlook handle inline styles differently, so check both if you can.
claude "send a test welcome email to my inbox using the sendEmail helper and confirm the response includes a data.id"Once that works, check the Resend dashboard's Logs tab. Every send shows its delivery status (delivered, bounced, complained), which is the fastest way to confirm your domain verification is actually working end to end, not just accepted by the API.
Wiring email up by hand, one endpoint at a time, is the kind of task Claude Code handles well in a single session. The harder part is everything around it: getting the domain verified correctly, making sure a failed send doesn't silently break signup, and keeping the templates consistent as you add more of them. The $29 Code Kit ships this already wired into the framework it sets up, welcome, receipt, and reset templates included, so you start from a working email system instead of building one from scratch.
Arrête de tout configurer. Place à la construction.
Des templates SaaS avec orchestration IA.
Posted by @speedy_devv
Arrête de tout configurer. Place à la construction.
Des templates SaaS avec orchestration IA.
Adding Authentication With Claude Code (Supabase Auth)
Add email/password signup, Google OAuth, magic links, protected routes, and session handling to a Next.js 16 app using Claude Code and Supabase Auth.
Building a Type-Safe API With Claude Code (oRPC + Zod)
How to build a fully type-safe API layer in Next.js 16 with oRPC and Zod, using Claude Code, so schema changes break the build instead of production.