Resend + React Email - トランザクションメール送信実践

初級 | 15分 で読める | 2026.04.24

公式ドキュメント

Resendとは

Resendは、開発者向けトランザクションメールAPIです。SendGridやMailgunの代替として、シンプルなAPIと高い到達率を提供します。

Resendの特徴

機能説明
シンプルなAPIRESTful APIで簡単に統合
React Email対応JSXでメールテンプレート作成
ドメイン認証SPF・DKIM・DMARC自動設定
Webhooks配信・開封・クリック・バウンスイベント
分析送信状況・開封率・クリック率のダッシュボード

ポイント: Resendは無料プランで月3,000通送信でき、個人プロジェクトにも最適です。

React Emailとは

React Emailは、Reactコンポーネントとしてメールテンプレートを記述できるライブラリです。HTMLメールの複雑さを抽象化し、型安全にメールを作成できます。

プロジェクトセットアップ

# Next.jsプロジェクト作成
npx create-next-app@latest my-email-app --typescript --app

cd my-email-app

# Resend SDKのインストール
npm install resend

# React Emailのインストール
npm install react-email @react-email/components
npm install -D @react-email/tailwind

環境変数

# .env.local
RESEND_API_KEY=YOUR_RESEND_API_KEY

実践メモ: Resend APIキーはhttps://resend.com/api-keysから取得します。

React Emailテンプレート

ディレクトリ構成

emails/
├── templates/
│ ├── WelcomeEmail.tsx
│ ├── ResetPasswordEmail.tsx
│ ├── OrderConfirmationEmail.tsx
│ └── components/
│ ├── Button.tsx
│ ├── Footer.tsx
│ └── Header.tsx
└── index.ts

ウェルカムメール

// emails/templates/WelcomeEmail.tsx
import {
 Body,
 Button,
 Container,
 Head,
 Heading,
 Hr,
 Html,
 Img,
 Link,
 Preview,
 Section,
 Text,
} from '@react-email/components';
import { Tailwind } from '@react-email/tailwind';
import * as React from 'react';

interface WelcomeEmailProps {
 username: string;
 loginUrl: string;
}

export const WelcomeEmail = ({ username, loginUrl }: WelcomeEmailProps) => {
 return (
 <Html>
 <Head />
 <Preview>Welcome to our platform, {username}!</Preview>
 <Tailwind>
 <Body className="bg-gray-100 font-sans">
 <Container className="mx-auto py-8 px-4 max-w-xl bg-white rounded-lg shadow-lg">
 <Img
 src="https://example.com/logo.png"
 width="150"
 height="50"
 alt="Logo"
 className="mx-auto"
 />
 
 <Heading className="text-2xl font-bold text-gray-800 text-center my-6">
 Welcome to MyApp!
 </Heading>
 
 <Text className="text-gray-700 text-base leading-6">
 Hi {username},
 </Text>
 
 <Text className="text-gray-700 text-base leading-6">
 Thank you for signing up! We're excited to have you on board.
 To get started, please verify your email address by clicking the button below.
 </Text>
 
 <Section className="text-center my-8">
 <Button
 href={loginUrl}
 className="bg-indigo-600 text-white px-6 py-3 rounded-lg font-semibold no-underline"
 >
 Get Started
 </Button>
 </Section>
 
 <Text className="text-gray-600 text-sm">
 If you didn't create an account, you can safely ignore this email.
 </Text>
 
 <Hr className="border-gray-300 my-6" />
 
 <Text className="text-gray-500 text-xs text-center">
 © 2026 MyApp. All rights reserved.
 <br />
 <Link href="https://example.com/unsubscribe" className="text-indigo-600">
 Unsubscribe
 </Link>
 </Text>
 </Container>
 </Body>
 </Tailwind>
 </Html>
 );
};

export default WelcomeEmail;

パスワードリセットメール

// emails/templates/ResetPasswordEmail.tsx
import {
 Body,
 Button,
 Container,
 Head,
 Heading,
 Html,
 Preview,
 Section,
 Text,
} from '@react-email/components';
import { Tailwind } from '@react-email/tailwind';

interface ResetPasswordEmailProps {
 username: string;
 resetLink: string;
 expiresIn: string;
}

export const ResetPasswordEmail = ({ username, resetLink, expiresIn }: ResetPasswordEmailProps) => {
 return (
 <Html>
 <Head />
 <Preview>Reset your password</Preview>
 <Tailwind>
 <Body className="bg-gray-100 font-sans">
 <Container className="mx-auto py-8 px-4 max-w-xl bg-white rounded-lg">
 <Heading className="text-2xl font-bold text-gray-800">
 Password Reset Request
 </Heading>
 
 <Text className="text-gray-700">
 Hi {username},
 </Text>
 
 <Text className="text-gray-700">
 We received a request to reset your password. Click the button below to create a new password:
 </Text>
 
 <Section className="text-center my-8">
 <Button
 href={resetLink}
 className="bg-red-600 text-white px-6 py-3 rounded-lg font-semibold"
 >
 Reset Password
 </Button>
 </Section>
 
 <Text className="text-gray-600 text-sm">
 This link will expire in {expiresIn}.
 </Text>
 
 <Text className="text-gray-600 text-sm">
 If you didn't request a password reset, please ignore this email or contact support if you have concerns.
 </Text>
 </Container>
 </Body>
 </Tailwind>
 </Html>
 );
};

export default ResetPasswordEmail;

注文確認メール

// emails/templates/OrderConfirmationEmail.tsx
import {
 Body,
 Column,
 Container,
 Head,
 Heading,
 Html,
 Preview,
 Row,
 Section,
 Text,
} from '@react-email/components';
import { Tailwind } from '@react-email/tailwind';

interface OrderItem {
 name: string;
 quantity: number;
 price: number;
}

interface OrderConfirmationEmailProps {
 orderNumber: string;
 customerName: string;
 items: OrderItem[];
 total: number;
 shippingAddress: string;
}

export const OrderConfirmationEmail = ({
 orderNumber,
 customerName,
 items,
 total,
 shippingAddress,
}: OrderConfirmationEmailProps) => {
 return (
 <Html>
 <Head />
 <Preview>Order #{orderNumber} confirmed</Preview>
 <Tailwind>
 <Body className="bg-gray-100 font-sans">
 <Container className="mx-auto py-8 px-4 max-w-2xl bg-white">
 <Heading className="text-2xl font-bold text-gray-800">
 Order Confirmation
 </Heading>
 
 <Text className="text-gray-700">
 Hi {customerName},
 </Text>
 
 <Text className="text-gray-700">
 Thank you for your order! Your order number is <strong>#{orderNumber}</strong>.
 </Text>
 
 <Section className="my-6">
 <Heading className="text-lg font-semibold text-gray-800">
 Order Summary
 </Heading>
 
 {items.map((item, index) => (
 <Row key={index} className="border-b border-gray-200 py-2">
 <Column>
 <Text className="text-gray-700">{item.name}</Text>
 </Column>
 <Column align="center">
 <Text className="text-gray-600">Qty: {item.quantity}</Text>
 </Column>
 <Column align="right">
 <Text className="text-gray-800 font-semibold">
 ${item.price.toFixed(2)}
 </Text>
 </Column>
 </Row>
 ))}
 
 <Row className="py-4">
 <Column>
 <Text className="text-gray-800 font-bold text-lg">Total</Text>
 </Column>
 <Column align="right">
 <Text className="text-gray-800 font-bold text-lg">
 ${total.toFixed(2)}
 </Text>
 </Column>
 </Row>
 </Section>
 
 <Section>
 <Heading className="text-lg font-semibold text-gray-800">
 Shipping Address
 </Heading>
 <Text className="text-gray-700 whitespace-pre-line">
 {shippingAddress}
 </Text>
 </Section>
 </Container>
 </Body>
 </Tailwind>
 </Html>
 );
};

export default OrderConfirmationEmail;

注意: メールクライアントはCSSサポートが限定的です。React EmailのコンポーネントはインラインCSSに変換されます

Resend送信関数

// lib/email.ts
import { Resend } from 'resend';
import { WelcomeEmail } from '@/emails/templates/WelcomeEmail';
import { ResetPasswordEmail } from '@/emails/templates/ResetPasswordEmail';
import { OrderConfirmationEmail } from '@/emails/templates/OrderConfirmationEmail';

const resend = new Resend(process.env.RESEND_API_KEY);

export async function sendWelcomeEmail(to: string, username: string) {
 const { data, error } = await resend.emails.send({
 from: 'MyApp <onboarding@example.com>',
 to,
 subject: 'Welcome to MyApp!',
 react: WelcomeEmail({
 username,
 loginUrl: 'https://example.com/login',
 }),
 });

 if (error) {
 console.error('Failed to send welcome email:', error);
 throw new Error('Failed to send email');
 }

 return data;
}

export async function sendPasswordResetEmail(to: string, username: string, resetToken: string) {
 const resetLink = `https://example.com/reset-password?token=${resetToken}`;

 const { data, error } = await resend.emails.send({
 from: 'MyApp Security <security@example.com>',
 to,
 subject: 'Reset your password',
 react: ResetPasswordEmail({
 username,
 resetLink,
 expiresIn: '1 hour',
 }),
 });

 if (error) {
 console.error('Failed to send password reset email:', error);
 throw new Error('Failed to send email');
 }

 return data;
}

export async function sendOrderConfirmationEmail(
 to: string,
 orderNumber: string,
 customerName: string,
 items: Array<{ name: string; quantity: number; price: number }>,
 total: number,
 shippingAddress: string
) {
 const { data, error } = await resend.emails.send({
 from: 'MyApp Orders <orders@example.com>',
 to,
 subject: `Order Confirmation #${orderNumber}`,
 react: OrderConfirmationEmail({
 orderNumber,
 customerName,
 items,
 total,
 shippingAddress,
 }),
 });

 if (error) {
 console.error('Failed to send order confirmation email:', error);
 throw new Error('Failed to send email');
 }

 return data;
}

ポイント: fromフィールドでは、検証済みドメインのメールアドレスを使用する必要があります。

Next.js統合

Server Actions

// app/actions/auth.ts
'use server';

import { sendWelcomeEmail, sendPasswordResetEmail } from '@/lib/email';
import { db } from '@/lib/db';
import { hash } from 'bcryptjs';

export async function signUp(formData: FormData) {
 const email = formData.get('email') as string;
 const username = formData.get('username') as string;
 const password = formData.get('password') as string;

 // ユーザー作成
 const hashedPassword = await hash(password, 12);
 const user = await db.user.create({
 data: {
 email,
 username,
 password: hashedPassword,
 },
 });

 // ウェルカムメール送信
 try {
 await sendWelcomeEmail(email, username);
 } catch (error) {
 console.error('Failed to send welcome email:', error);
 // メール送信失敗はサインアップ自体は成功させる
 }

 return { success: true, userId: user.id };
}

export async function requestPasswordReset(email: string) {
 const user = await db.user.findUnique({ where: { email } });

 if (!user) {
 // セキュリティのため、ユーザーが存在しなくても成功レスポンス
 return { success: true };
 }

 // リセットトークン生成
 const resetToken = crypto.randomUUID();
 const expiresAt = new Date(Date.now() + 3600000); // 1時間後

 await db.passwordResetToken.create({
 data: {
 userId: user.id,
 token: resetToken,
 expiresAt,
 },
 });

 // パスワードリセットメール送信
 await sendPasswordResetEmail(email, user.username, resetToken);

 return { success: true };
}

API Route

// app/api/orders/confirm/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { sendOrderConfirmationEmail } from '@/lib/email';
import { db } from '@/lib/db';

export async function POST(request: NextRequest) {
 const { orderId } = await request.json();

 const order = await db.order.findUnique({
 where: { id: orderId },
 include: {
 items: true,
 customer: true,
 },
 });

 if (!order) {
 return NextResponse.json({ error: 'Order not found' }, { status: 404 });
 }

 await sendOrderConfirmationEmail(
 order.customer.email,
 order.orderNumber,
 order.customer.name,
 order.items.map((item) => ({
 name: item.productName,
 quantity: item.quantity,
 price: item.price,
 })),
 order.total,
 order.shippingAddress
 );

 return NextResponse.json({ success: true });
}

実践メモ: トランザクションメールの送信失敗は、バックグラウンドジョブでリトライするのがベストプラクティスです

バッチ送信

// lib/email.ts
export async function sendBulkEmails(recipients: Array<{ email: string; username: string }>) {
 const { data, error } = await resend.batch.send(
 recipients.map((recipient) => ({
 from: 'MyApp <news@example.com>',
 to: recipient.email,
 subject: 'Monthly Newsletter',
 react: WelcomeEmail({
 username: recipient.username,
 loginUrl: 'https://example.com/login',
 }),
 }))
 );

 if (error) {
 console.error('Batch send failed:', error);
 throw new Error('Failed to send batch emails');
 }

 return data;
}

Webhooks

// app/api/webhooks/resend/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';

export async function POST(request: NextRequest) {
 const payload = await request.json();

 const { type, data } = payload;

 switch (type) {
 case 'email.sent':
 await db.emailLog.create({
 data: {
 emailId: data.email_id,
 status: 'sent',
 sentAt: new Date(data.created_at),
 },
 });
 break;

 case 'email.delivered':
 await db.emailLog.update({
 where: { emailId: data.email_id },
 data: {
 status: 'delivered',
 deliveredAt: new Date(data.created_at),
 },
 });
 break;

 case 'email.opened':
 await db.emailLog.update({
 where: { emailId: data.email_id },
 data: {
 openedAt: new Date(data.created_at),
 openCount: { increment: 1 },
 },
 });
 break;

 case 'email.clicked':
 await db.emailLog.update({
 where: { emailId: data.email_id },
 data: {
 clickedAt: new Date(data.created_at),
 clickCount: { increment: 1 },
 },
 });
 break;

 case 'email.bounced':
 await db.emailLog.update({
 where: { emailId: data.email_id },
 data: {
 status: 'bounced',
 bounceReason: data.bounce.type,
 },
 });
 break;

 case 'email.complained':
 await db.emailLog.update({
 where: { emailId: data.email_id },
 data: {
 status: 'complained',
 },
 });
 // スパム報告されたアドレスを配信停止
 await db.user.update({
 where: { email: data.to },
 data: { emailOptOut: true },
 });
 break;
 }

 return NextResponse.json({ success: true });
}

注意: Webhook URLはResendダッシュボードで設定し、署名検証を実装してください

React Emailプレビュー

# 開発サーバー起動
npm run email
// package.json
{
 "scripts": {
 "email": "email dev"
 }
}

http://localhost:3000 でプレビュー可能

添付ファイル

// lib/email.ts
export async function sendInvoiceEmail(to: string, invoicePdf: Buffer) {
 const { data, error } = await resend.emails.send({
 from: 'MyApp Billing <billing@example.com>',
 to,
 subject: 'Your Invoice',
 react: InvoiceEmail({ customerName: 'John Doe' }),
 attachments: [
 {
 filename: 'invoice.pdf',
 content: invoicePdf,
 },
 ],
 });

 if (error) {
 throw new Error('Failed to send invoice');
 }

 return data;
}

ポイント: 添付ファイルはBufferまたはbase64文字列として渡します。

テスト

// __tests__/email.test.ts
import { render } from '@react-email/render';
import { WelcomeEmail } from '@/emails/templates/WelcomeEmail';

describe('WelcomeEmail', () => {
 it('renders with correct props', () => {
 const html = render(
 WelcomeEmail({
 username: 'testuser',
 loginUrl: 'https://example.com/login',
 })
 );

 expect(html).toContain('testuser');
 expect(html).toContain('https://example.com/login');
 });
});

まとめ

この記事では、手順を追って動かしながら重要な確認ポイントを押さえることが大切です。まずは学習用の最小構成で動作確認し、本番利用する前にセキュリティ、権限、エラー処理、運用手順を見直してください。

参考リソース

  • 公式ドキュメント - Resend + React Email - トランザクションメール送信実践 を確認するための一次情報

関連記事

← 一覧に戻る
PR
PR
PR
PR