Astro × Cloudflare Pages × Resend でお問い合わせフォームを実装する【完全ガイド】
Astro製の静的サイトにサーバーレスなお問い合わせフォームを実装する方法を解説。Cloudflare Pages FunctionsとResend APIを使って、無料でメール送信機能を追加します。
静的サイトにフォームを置きたい問題
Astroで静的サイトを運用していると、いつかぶつかるのがお問い合わせフォーム問題です。
HTMLでフォームを書くのは簡単。でも「送信されたデータをどこで受け取るか?」が悩みどころになります。静的サイトにはバックエンドがないので、フォームのデータを処理するサーバーが存在しません。Astro でのブログ構築の基本はAstro ブログチュートリアルで別途解説予定です。
選択肢としては Formspree や Google Forms への埋め込みもありますが、できれば自分のドメインで完結させたい。外部サービスのブランドロゴも出したくない。
そこで使ったのが Cloudflare Pages Functions + Resend API の組み合わせです。このブログ(ToolCraft Lab)に実際に実装した内容を、コード付きでそのまま紹介します。
技術構成
今回の構成はこのようになっています。
| 技術 | 役割 | 料金 |
|---|---|---|
| Astro | 静的サイト生成(SSG) | 無料 |
| Cloudflare Pages | ホスティング + CDN | 無料(月500回ビルド) |
| Cloudflare Pages Functions | サーバーレスAPI | 無料(日10万リクエスト) |
| Resend | メール送信API | 無料(月100通、1ドメイン) |
全部無料で運用できます。個人サイトのお問い合わせフォームなら、この無料枠で十分すぎるほどです。
データの流れはシンプルです。
ブラウザ(フォーム送信)
→ Cloudflare Pages Functions(/api/contact)
→ Resend API(メール送信)
→ 自分のメールボックスに届く
Resend のセットアップ
1. アカウント作成
Resend公式サイトでアカウントを作成します。GitHubアカウントで登録できるので一瞬です。
2. ドメイン認証(DNS設定)
Resendのダッシュボードから Domains > Add Domain で自分のドメインを追加します。表示されるDNSレコード(SPF, DKIM, DMARC)をCloudflareのDNS設定に追加してください。
これをやらないと noreply@yourdomain.com のような自ドメインからメールを送れません。認証が完了するまで数分〜数十分かかることがあります。
3. APIキー取得
Resendダッシュボードの API Keys から新しいキーを発行します。パーミッションは Sending access だけで十分です。
4. 環境変数に設定
Cloudflareダッシュボードで Pages > 対象プロジェクト > Settings > Environment variables に以下を追加します。
| 変数名 | 値 |
|---|---|
RESEND_API_KEY | re_xxxxxxxxxxxx(発行したキー) |
本番(Production)とプレビュー(Preview)の両方に設定しておくと便利です。
API の実装(functions/api/contact.ts)
Cloudflare Pages Functionsは、functions/ ディレクトリにファイルを置くだけでサーバーレスAPIとして動きます。functions/api/contact.ts を作ると、/api/contact というエンドポイントが自動的に生成されます。
実際に動いているコード全文がこちらです。
interface Env {
RESEND_API_KEY: string;
}
interface ContactRequest {
name: string;
email: string;
category: string;
message: string;
_honeypot?: string;
}
export const onRequestPost: PagesFunction<Env> = async (context) => {
const corsHeaders = {
'Access-Control-Allow-Origin': 'https://toolcraftlab.dev',
'Content-Type': 'application/json',
};
try {
const body = (await context.request.json()) as ContactRequest;
// ハニーポットによるスパム対策(botはhidden fieldを埋める)
if (body._honeypot) {
// botにはスルーしたように見せる
return new Response(JSON.stringify({ success: true }), {
status: 200,
headers: corsHeaders,
});
}
// バリデーション
if (!body.name?.trim() || !body.email?.trim() || !body.message?.trim()) {
return new Response(
JSON.stringify({ error: 'お名前、メールアドレス、お問い合わせ内容は必須です。' }),
{ status: 400, headers: corsHeaders }
);
}
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(body.email)) {
return new Response(
JSON.stringify({ error: 'メールアドレスの形式が正しくありません。' }),
{ status: 400, headers: corsHeaders }
);
}
if (body.message.trim().length < 10) {
return new Response(
JSON.stringify({ error: 'お問い合わせ内容は10文字以上で入力してください。' }),
{ status: 400, headers: corsHeaders }
);
}
const categoryLabel = body.category || 'その他';
// Resend APIでメール送信
const resendRes = await fetch('https://api.resend.com/emails', {
method: 'POST',
headers: {
'Authorization': `Bearer ${context.env.RESEND_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
from: 'ToolCraft Lab <noreply@toolcraftlab.dev>',
to: ['info@toolcraftlab.dev'],
reply_to: body.email,
subject: `【お問い合わせ】${categoryLabel} - ${body.name}`,
html: `
<h2>お問い合わせがありました</h2>
<table style="border-collapse:collapse;width:100%;max-width:600px;">
<tr style="border-bottom:1px solid #eee;">
<td style="padding:8px;font-weight:bold;width:120px;">お名前</td>
<td style="padding:8px;">${escapeHtml(body.name)}</td>
</tr>
<tr style="border-bottom:1px solid #eee;">
<td style="padding:8px;font-weight:bold;">メールアドレス</td>
<td style="padding:8px;"><a href="mailto:${escapeHtml(body.email)}">${escapeHtml(body.email)}</a></td>
</tr>
<tr style="border-bottom:1px solid #eee;">
<td style="padding:8px;font-weight:bold;">カテゴリ</td>
<td style="padding:8px;">${escapeHtml(categoryLabel)}</td>
</tr>
<tr>
<td style="padding:8px;font-weight:bold;vertical-align:top;">内容</td>
<td style="padding:8px;white-space:pre-wrap;">${escapeHtml(body.message)}</td>
</tr>
</table>
`,
}),
});
if (!resendRes.ok) {
const errorData = await resendRes.text();
console.error('Resend API error:', errorData);
return new Response(
JSON.stringify({ error: '送信に失敗しました。しばらくしてから再度お試しください。' }),
{ status: 500, headers: corsHeaders }
);
}
return new Response(
JSON.stringify({ success: true }),
{ status: 200, headers: corsHeaders }
);
} catch (e) {
console.error('Contact form error:', e);
return new Response(
JSON.stringify({ error: 'サーバーエラーが発生しました。' }),
{ status: 500, headers: corsHeaders }
);
}
};
function escapeHtml(str: string): string {
return str
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
ポイント解説
ハニーポットによるスパム対策: _honeypot フィールドが入力されていたらbot判定。成功レスポンスを返して静かにスルーします。botには「送信できた」と思わせるのがコツです。
バリデーション: 必須フィールドのチェック、メールアドレスの形式チェック、メッセージの最低文字数チェック。サーバーサイドでしっかりやります。
XSSエスケープ: escapeHtml() でユーザー入力をサニタイズしてからHTMLメールに埋め込みます。メール本文にスクリプトを注入されないための基本対策です。
Resend APIの呼び出し: reply_to に送信者のメールアドレスを設定しているので、届いたメールにそのまま返信できます。
フォームの実装(フロントエンド側)
フォームのHTMLと送信処理の主要部分です。Astroページとして実装しています。
<form id="contact-form" class="contact-form">
<div class="form-group">
<label for="name">お名前 <span class="required">*</span></label>
<input type="text" id="name" name="name" required placeholder="山田 太郎" autocomplete="name" />
</div>
<div class="form-group">
<label for="email">メールアドレス <span class="required">*</span></label>
<input type="email" id="email" name="email" required placeholder="example@email.com" autocomplete="email" />
</div>
<div class="form-group">
<label for="category">お問い合わせの種類</label>
<select id="category" name="category">
<option value="記事に関するご質問・ご指摘">記事に関するご質問・ご指摘</option>
<option value="お仕事のご相談">お仕事のご相談</option>
<option value="広告掲載・メディア連携">広告掲載・メディア連携</option>
<option value="その他">その他</option>
</select>
</div>
<div class="form-group">
<label for="message">お問い合わせ内容 <span class="required">*</span></label>
<textarea id="message" name="message" rows="8" required placeholder="お問い合わせ内容をご記入ください(10文字以上)" minlength="10"></textarea>
</div>
<!-- ハニーポット(CSSで非表示にする) -->
<div class="hp-field" aria-hidden="true">
<input type="text" name="_honeypot" id="_honeypot" tabindex="-1" autocomplete="off" />
</div>
<button type="submit" id="submit-btn" class="submit-btn">
<span class="btn-text">送信する</span>
<span class="btn-loading" style="display:none">送信中...</span>
</button>
</form>
送信処理のJavaScript部分です。
form.addEventListener('submit', async (e) => {
e.preventDefault();
// ローディング状態に切り替え
submitBtn.disabled = true;
btnText.style.display = 'none';
btnLoading.style.display = 'inline';
const data = {
name: (document.getElementById('name') as HTMLInputElement).value.trim(),
email: (document.getElementById('email') as HTMLInputElement).value.trim(),
category: (document.getElementById('category') as HTMLSelectElement).value,
message: (document.getElementById('message') as HTMLTextAreaElement).value.trim(),
_honeypot: (document.getElementById('_honeypot') as HTMLInputElement).value,
};
try {
const res = await fetch('/api/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
const json = await res.json();
if (res.ok && json.success) {
// GA4でコンバージョン計測
gtag('event', 'contact_submit', { category: data.category });
form.style.display = 'none';
successMsg.style.display = 'block';
} else {
errorText.textContent = json.error || '送信に失敗しました。';
errorMsg.style.display = 'block';
}
} catch {
errorText.textContent = 'ネットワークエラーが発生しました。';
errorMsg.style.display = 'block';
} finally {
submitBtn.disabled = false;
btnText.style.display = 'inline';
btnLoading.style.display = 'none';
}
});
ポイント
- ローディング状態の管理: 送信ボタンを無効化して「送信中…」表示に切り替え。二重送信を防ぎます
- 成功/エラーメッセージ: 送信成功時はフォームを隠して完了メッセージを表示。エラー時はAPIから返されたメッセージをそのまま表示
- GA4イベント計測:
gtag('event', 'contact_submit', ...)でお問い合わせ送信をコンバージョンとして計測。どのカテゴリの問い合わせが多いかも分かります
スパム対策
今回はreCAPTCHAを使わず、ハニーポット方式だけでスパム対策をしています。
ハニーポットの仕組み
- フォームにCSSで非表示にした入力フィールド(
_honeypot)を設置する - 人間はこのフィールドが見えないので入力しない
- botはHTMLを解析してすべてのフィールドを埋めるので、このフィールドにも値が入る
- サーバー側で
_honeypotに値が入っていたらbot判定 → 成功レスポンスを返して何もしない
.hp-field {
position: absolute;
left: -9999px;
opacity: 0;
height: 0;
overflow: hidden;
}
なぜreCAPTCHAを使わなかったか
- UXを損なわない: 「画像を選んでください」系のチャレンジは、ユーザーにストレスを与えます
- 外部依存を減らす: Googleのスクリプトを読み込む必要がなく、ページの表示速度にも影響しません
- 個人サイト規模なら十分: 大量のスパムが来るサイトならreCAPTCHAが必要ですが、個人ブログならハニーポットで十分対処できます
もしスパムが増えてきたら、Cloudflare TurnstileやreCAPTCHA v3を追加することも可能です。
まとめ
Astro + Cloudflare Pages + Resend の構成で、完全無料のお問い合わせフォームが実現できました。
- Cloudflare Pages Functionsのおかげで、別途バックエンドサーバーを用意する必要がない
- Resendの無料枠(月100通)は個人サイトなら余裕
- ハニーポットでreCAPTCHAなしのシンプルなスパム対策
- 全体で100行ちょっとのコードで完結
Cloudflare Pages Functionsは今回のようなフォーム処理だけでなく、OGP画像の動的生成やWebhookの受信など、いろいろなAPIエンドポイントに活用できます。静的サイトの「ちょっとだけサーバーサイドが必要」な場面で使いやすい仕組みです。
今後の改善としては、レート制限(同一IPからの連続送信を制限)や、送信者への自動返信メールの追加を考えています。CI/CDでデプロイを自動化したい場合はGitHub Actions 入門ガイドも参考になります。
よくある質問
Resendの無料枠は足りますか?
Resendの無料プランは月100通・1ドメインです。個人サイトや小規模ブログのお問い合わせフォームなら十分すぎる量です。仮に毎日3通来ても月90通で収まります。もし上限に達しそうなら、月20ドルのProプランで月5,000通まで送れます。
reCAPTCHAは必要ですか?
個人サイト規模であれば、ハニーポット方式で十分です。導入が簡単で、ユーザー体験を損なわないメリットがあります。もしスパムが増えてきたら、Cloudflare Turnstile(無料)を追加するのがおすすめです。reCAPTCHAと違い、多くの場合ユーザーにチャレンジを表示せずにbot判定できます。
Resend以外のメール送信サービスは使えますか?
同じ構成で SendGrid や Mailgun、Amazon SES なども使えます。Cloudflare Pages Functionsの中でそれぞれのAPIを叩くだけなので、fetch の宛先とリクエストボディを変えるだけです。Resendを選んだ理由は、APIがシンプルで、セットアップが最も速かったからです。