Claude Code でトランザクションメールを追加する(Resend + React Email)
Resend と React Email を使って、Next.js 16 アプリでウェルカム・レシート・パスワードリセットのメールを作る方法。テンプレートと送信ロジックは Claude Code が書きます。
どんな SaaS も初日に 3 通のメールが要ります。ウェルカム、レシート、パスワードリセットです。これを間違えると(スパム行き、DKIM レコードの欠落、タイプミスで黙って失敗)、ユーザーはオンボーディングを終える前に離れていきます。これは、Next.js 16 アプリで Resend と React Email を繋ぐ完全な手順です。ドメインの検証から、Server Action での動く送信までをカバーします。
トランザクションメールが厄介な理由
トランザクションメールは、実際に出荷するまでは単純に見えます。検証済みの送信ドメインがないとメールはスパムに行くか、そもそも送れません。Gmail、Outlook、Apple Mail は CSS のサポートがバラバラなので、それらすべてで一貫してレンダリングされる HTML が要ります。ユーザーが待っているレスポンスをブロックせずに、適切なタイミング(サインアップ完了後、決済成功後)で送信をトリガーする必要があります。そして、うまくいったと思い込むのではなく、送信が実際に失敗したのを知る必要があります。
Resend は配信とドメインの評判を扱います。React Email はテンプレートを扱うので、手書きのテーブルレイアウトではなく JSX を書けます。ここで Claude Code が役立つのは、両方の API をすでによく知っていて、配管(Server Actions、エラー処理、環境変数)を最初のパスで正しく繋げるからです。何を作るかを正確に伝える限りは。
Resend と React Email をインストールする
パッケージは 3 つ必要です。メールを送る Resend SDK、テンプレートを作る React Email のコンポーネントライブラリ、そしてローカルでプレビューする React Email CLI です。3 つすべてをインストールするよう Claude Code に頼めば、プロジェクトのパッケージマネージャを自動で使ってくれます。
npm install resend @react-email/components
npm install -D react-emailresend は Resend API と話すクライアントです。@react-email/components は、インライン CSS と格闘せずにメールクライアント全体で正しくレンダリングされる、あらかじめ用意されたメールセーフなプリミティブ(Html、Body、Container、Button、Text)を提供します。react-email は開発時だけの依存で、あとで使うローカルプレビューサーバーを動かします。
API キーを取得し、送信ドメインを検証する
resend.com でサインアップし、ダッシュボードから API キーを作ります。これは環境変数として保存し、テンプレートにハードコードしたりコミットしたりは絶対にしないでください。
# .env.local
RESEND_API_KEY=re_xxxxxxxxxxxxxxxxxxxx
EMAIL_FROM="Your App <hello@yourdomain.com>"検証済みのドメインがないと、Resend は共有の onboarding@resend.dev アドレスを使って自分のアカウントのメールにしか送らせてくれません。最初のテストには十分ですが、実際のユーザーはそこから何も受け取れません。本物の受信箱に送るには、Resend ダッシュボードの Domains に行き、ドメインを追加して、提示される SPF と DKIM のレコードを DNS プロバイダーに追加します。検証はレコードが伝播すればたいてい数分で済みますが、レジストラによってはもう少しかかることもあります。
どのドメインを使うかを Claude Code に伝えておけば、本物の送信をテストする前に検証状況を確認するよう促してくれます。
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"React Email のテンプレートを作る
テンプレートは、プレビューサーバーが見つけられるように、そしてアプリの UI コンポーネントと分けておくために、専用のフォルダに置きます。各テンプレートは @react-email/components のプリミティブから作られたただの React コンポーネントで、内部でメールセーフな HTML にマッピングされます。
// 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>
);
}ここでインラインスタイルにしているのは意図的で、手抜きではありません。ほとんどのメールクライアントは <style> ブロックやクラスベースの CSS を剥ぎ取るので、@react-email/components はスタイルを自動的に最終 HTML にインライン展開します。あなたは普通に見える JSX を書くだけで、メールセーフになって出てきます。
lib/email ヘルパー
メールを送るすべてのルートは同じ Resend クライアントを必要とするので、あちこちで作り直すのではなく、1 つの共有ヘルパーに置くのが筋です。こうすると、あとでログやフォールバックを追加する場所も 1 か所にまとまります。
// 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 };
}Resend SDK は送信失敗時に例外を投げず、レスポンスオブジェクトに error フィールドを返します。そのチェックを飛ばすと、バウンスや拒否されたメールが、ログ上では成功とまったく同じに見えてしまいます。上のヘルパーは、すべての呼び出し元に両方のケースを明示的に扱わせます。
Server Action から送る(サインアップ後のウェルカムメール)
ウェルカムメールは、ユーザーがアプリに着地する前にメールプロバイダーのレスポンスを待たせることなく、アカウント作成の直後に飛ばすべきです。送信を、サインアップ完了後に走る Server Action の中に包みます。
// 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");
}ウェルカムメールの失敗が、アカウント作成をブロックすることは決してあってはいけません。その時点でユーザーはすでにアカウントを持っているので、このコードは例外を投げるのではなく失敗をログに残してダッシュボードへ進みます。そのログ行が大事です。既存のエラートラッカーがあるなら、そこに繋ぎ込むよう Claude Code に頼みましょう。そうすればメール失敗の急増が、サーバーログに静かに埋もれるのではなく、実際に気づかれます。
Route Handler から送る(パスワードリセット)
パスワードリセットは送信の前にトークンを生成する必要があり、リダイレクトではなく JSON レスポンスを期待するフォームからトリガーされることが多いので、ここでは Server Action よりも Route Handler が合います。
// 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 });
}アカウントが存在するかどうかにかかわらず同じ成功レスポンスを返すのは、小さいけれど本物のセキュリティ上の配慮です。このパターンは Claude Code に明示的に頼みましょう(「レスポンスからアカウントの有無を漏らさないで」)。素朴な実装は未知のメールに 404 を返しますが、それはまさに渡したくない情報だからです。
レシートメールを作る
レシートはウェルカムメールと同じパターンに従います。違うのは中身と、トリガーがフォーム送信ではなく、たいていは Stripe の webhook である点だけです。
// 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>
);
}これは Stripe の webhook ハンドラの中で、checkout.session.completed や invoice.paid のイベントのあとに、先ほどと同じ sendEmail ヘルパーを使って呼びます。Stripe は失敗した webhook をリトライするので、そのハンドラの中でメール送信が例外を投げるなら、送信の失敗が原因で Stripe が webhook 全体を失敗とみなしてイベント全体をリトライしないよう、送信を包んでおきましょう。
テンプレートをローカルでプレビューする
フォントサイズを微調整するたびに本物のメールを送りたくはありません。React Email CLI はローカルサーバーを立ち上げ、テンプレートをライブでレンダリングして保存のたびにリロードします。API キーもネットワーク呼び出しも要りません。
npx react-email devこれは localhost:3000(または次に空いているポート)でプレビューを開き、emails/ フォルダのすべてのコンポーネントを受信箱に表示されるとおりにレンダリングし、生の HTML 出力を確認するトグルも付けてくれます。テンプレートがデフォルトの場所にないなら、--dir でカスタムフォルダを指定しましょう。各テンプレートにモックの props を渡すよう Claude Code に頼めば、プレビューが空欄ではなく本物らしい内容を表示します。
エラーとべき等性を扱う
本番では 2 つの失敗モードが効いてきます。1 つ目、Server Action の中での Resend への呼び出しが遅かったり失敗したりすると、待つ時間の妥当な上限を決めておかない限りリクエストが宙ぶらりんになり得ます。2 つ目、リトライ(ユーザーの送信ダブルクリック、webhook の二重発火)は、何も止めなければ重複送信を引き起こし得ます。
重複対策として、webhook のような外部イベントでトリガーされる送信にはべき等キーを渡し、リトライされた webhook が同じレシートを 2 回送らないようにします。
await resend.emails.send(
{
from: process.env.EMAIL_FROM!,
to: user.email,
subject: "Payment received",
react: ReceiptEmail({ amount, planName, invoiceDate }),
},
{ idempotencyKey: `receipt-${invoiceId}` }
);Stripe のインボイス ID をべき等キーに使うと、Resend は同じキーのリトライされたリクエストを認識して再送をスキップし、2 通目のレシートを飛ばしません。これがいちばん効くのは webhook でトリガーされる送信で、そこではリトライはエッジケースではなく想定された挙動です。
出荷前にテストする
これをサインアップや checkout に繋ぐ前に、自分の受信箱に本物のテストメールを送って、3 つを手で確認しましょう。件名が途切れていないこと、ボタンが実際に正しい URL にリンクしていること、そしてメールがスパムに行かないこと。Gmail と Outlook はインラインスタイルの扱いが違うので、できれば両方確認してください。
claude "send a test welcome email to my inbox using the sendEmail helper and confirm the response includes a data.id"これが動いたら、Resend ダッシュボードの Logs タブを確認します。すべての送信が配信状況(delivered、bounced、complained)を表示し、これがドメインの検証が API に受理されただけでなく、端から端まで実際に機能していることを確認する最速の方法です。
メールを 1 エンドポイントずつ手で繋ぐのは、Claude Code が 1 セッションでうまくこなす類のタスクです。難しいのはその周りのすべてです。ドメインを正しく検証すること、送信の失敗が黙ってサインアップを壊さないようにすること、そしてテンプレートを増やしても一貫性を保つこと。$29 の Code Kit は、それがセットアップするフレームワークにこれをすでに繋いだ状態で出荷します。ウェルカム・レシート・リセットのテンプレート込みなので、あなたはゼロから作るのではなく、動くメールシステムから始められます。
Posted by @speedy_devv

