この記事の要点
• Resendで信頼性の高いトランザクションメールをシンプルに送信
• React EmailでメールテンプレートをReactコンポーネントとして管理
• Next.js App Routerと統合し、サーバーアクションから直接メール送信
Resendとは
Resendは、開発者向けトランザクションメールAPIです。SendGridやMailgunの代替として、シンプルなAPIと高い到達率を提供します。
Resendの特徴
| 機能 | 説明 |
|---|---|
| シンプルなAPI | RESTful 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=re_xxxxx
実践メモ: 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');
});
});