Astro製の静的サイトにサーバーレスなお問い合わせフォームを実装する方法を解説。Cloudflare Pages FunctionsとResend APIを使って、無料でメール送信機能を追加します。
個人開発
公開: by ToolCraft Lab 約9分で読めます

Astro × Cloudflare Pages × Resend でお問い合わせフォームを実装する【完全ガイド】

Astro製の静的サイトにサーバーレスなお問い合わせフォームを実装する方法を解説。Cloudflare Pages FunctionsとResend APIを使って、無料でメール送信機能を追加します。

#Astro#Cloudflare#個人開発#Resend

静的サイトにフォームを置きたい問題

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_KEYre_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, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#39;');
}

ポイント解説

ハニーポットによるスパム対策: _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を使わず、ハニーポット方式だけでスパム対策をしています。

ハニーポットの仕組み

  1. フォームにCSSで非表示にした入力フィールド(_honeypot)を設置する
  2. 人間はこのフィールドが見えないので入力しない
  3. botはHTMLを解析してすべてのフィールドを埋めるので、このフィールドにも値が入る
  4. サーバー側で _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以外のメール送信サービスは使えますか?

同じ構成で SendGridMailgunAmazon SES なども使えます。Cloudflare Pages Functionsの中でそれぞれのAPIを叩くだけなので、fetch の宛先とリクエストボディを変えるだけです。Resendを選んだ理由は、APIがシンプルで、セットアップが最も速かったからです。