• #Resend
  • #Cloudflare Pages Functions
  • #フォーム
  • #メール送信
  • #Vuelidate
開発tax-lpメモ

Cloudflare Pages FunctionsとResendでお問い合わせフォームを実装

LPプロジェクトにお問い合わせフォームを追加した。SSG(静的サイト生成)でデプロイしている Cloudflare Pages 上で、メール送信のバックエンドをどう実装するかが一番のポイントだった。結論として、Cloudflare Pages Functions + Resend API の組み合わせで、サーバーレスなメール送信を実現した。


背景: SSGサイトでメールを送るには

SSGの制約

このプロジェクトは Nuxt 4 の SSG モードで Cloudflare Pages にデプロイしている。静的ファイルだけが配信される構成なので、フォームの送信先となるバックエンド API をどこかに用意する必要がある。

選択肢はいくつかある。

方式概要判断
外部 API サービス(Formspree 等)サードパーティにフォームデータを送る柔軟性が低い
自前の API サーバーVPS や Cloud Run でバックエンドを運用管理コストが高い
Cloudflare Pages FunctionsPages プロジェクト内に functions/ を置くだけ追加インフラ不要

Cloudflare Pages Functions を選んだ。理由は、既に Cloudflare Pages でホスティングしているので追加のインフラが不要なこと、functions/ ディレクトリにファイルを置くだけでエンドポイントが生えること、そしてシークレット(API キー)を環境変数として安全に管理できることの3点。

メール送信サービスの選定

メール送信 API には Resend を使う。アカウントは以前から持っていた。DX がよく、API がシンプルで、Cloudflare Workers/Pages Functions との相性もよい。


Resend の設定

ドメイン認証

Resend でメールを送るには、送信元ドメインの DNS 認証が必要になる。独自ドメインのサブドメインとして no-reply.example.com を Resend に登録し、DNS レコード(SPF、DKIM)を設定済みだった。

ここで後にハマるポイントがあった。コード上で送信元を [email protected] と書いてしまい、認証済みドメインと一致しなかった。正しくは [email protected] のように、認証済みサブドメインを使う必要がある。

API キーの管理

Resend の API キーは .env.dev.vars(Cloudflare Pages Functions のローカル開発用)の両方に設定した。

# .dev.vars(wrangler pages dev が読み込むファイル)
RESEND_API_KEY=re_xxxxxxxxxxxxxxxxxxxx

本番環境では Cloudflare Dashboard の「Settings > Environment variables」からシークレットとして登録する。


実装の全体構成

新規作成したファイルと変更したファイルの一覧。

新規作成(6ファイル)

ファイル役割
functions/api/contact.tsPages Function。Resend API を呼び出すバックエンド
app/composables/useContactForm.tsフォームの状態管理・バリデーション・送信処理
app/components/lp/shared/ContactForm.vueフォーム UI コンポーネント
app/pages/contact.vueお問い合わせページ
.dev.varsローカル開発用の環境変数
_routes.jsonCloudflare Pages の Functions ルーティング設定

変更(4ファイル)

ファイル変更内容
app/components/lp/shared/Header.vueナビに「お問い合わせ」リンク追加
app/components/lp/shared/Cta.vueCTA ボタンのリンク先を /contact に変更
.gitignore.wrangler ディレクトリを除外
デプロイスクリプト_routes.json のコピー処理追加

Cloudflare Pages Functions: バックエンド実装

functions/api/contact.ts

functions/ ディレクトリ配下に置いたファイルは、Cloudflare Pages が自動的にサーバーレス関数として認識する。functions/api/contact.ts/api/contact エンドポイントになる。

// functions/api/contact.ts
interface Env {
  RESEND_API_KEY: string
}

interface ContactRequest {
  name: string
  email: string
  company?: string
  phone?: string
  message: string
  industry?: string
}

export const onRequestPost: PagesFunction<Env> = async (context) => {
  const corsHeaders = {
    'Access-Control-Allow-Origin': '*',
    'Access-Control-Allow-Methods': 'POST, OPTIONS',
    'Access-Control-Allow-Headers': 'Content-Type',
  }

  try {
    const body = await context.request.json() as ContactRequest
    const { name, email, company, phone, message, industry } = body

    // バリデーション
    if (!name || !email || !message) {
      return new Response(
        JSON.stringify({ error: '必須項目が入力されていません' }),
        { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
      )
    }

    // Resend API でメール送信
    const resendResponse = 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: 'お問い合わせフォーム <[email protected]>',
        to: ['[email protected]'],
        subject: `【お問い合わせ】${name}様(${industry || '未指定'}`,
        html: buildEmailHtml({ name, email, company, phone, message, industry }),
      }),
    })

    if (!resendResponse.ok) {
      const errorData = await resendResponse.json()
      console.error('Resend API error:', errorData)
      return new Response(
        JSON.stringify({ error: 'メール送信に失敗しました', details: errorData }),
        { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
      )
    }

    const data = await resendResponse.json()
    return new Response(
      JSON.stringify({ success: true, id: data.id }),
      { status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
    )
  } catch (error) {
    console.error('Contact form error:', error)
    return new Response(
      JSON.stringify({ error: 'サーバーエラーが発生しました' }),
      { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
    )
  }
}

// CORS プリフライト対応
export const onRequestOptions: PagesFunction = async () => {
  return new Response(null, {
    headers: {
      'Access-Control-Allow-Origin': '*',
      'Access-Control-Allow-Methods': 'POST, OPTIONS',
      'Access-Control-Allow-Headers': 'Content-Type',
    },
  })
}

const buildEmailHtml = (data: ContactRequest): string => {
  return `
    <h2>お問い合わせがありました</h2>
    <table>
      <tr><td><strong>お名前</strong></td><td>${data.name}</td></tr>
      <tr><td><strong>メールアドレス</strong></td><td>${data.email}</td></tr>
      <tr><td><strong>会社名</strong></td><td>${data.company || '未入力'}</td></tr>
      <tr><td><strong>電話番号</strong></td><td>${data.phone || '未入力'}</td></tr>
      <tr><td><strong>業種</strong></td><td>${data.industry || '未指定'}</td></tr>
    </table>
    <h3>お問い合わせ内容</h3>
    <p>${data.message.replace(/\n/g, '<br>')}</p>
  `
}

ポイント: PagesFunction の型

Cloudflare Pages Functions は PagesFunction<Env> 型を使う。Env インターフェースに環境変数の型を定義しておくと、context.env.RESEND_API_KEY で型安全にアクセスできる。

ポイント: CORS 対応

SSG ビルドされた静的ページから /api/contact に POST する構成のため、同一オリジンであれば CORS は不要だが、wrangler pages dev でのローカルテスト時にポートが異なる場合を想定して CORS ヘッダーを付けている。onRequestOptions で OPTIONS プリフライトにも対応した。

_routes.json によるルーティング制御

Cloudflare Pages Functions は、デフォルトでは全リクエストを Functions に通そうとする。SSG サイトでは静的ファイルをそのまま配信し、/api/* だけ Functions に回したい。_routes.json でこれを制御する。

{
  "version": 1,
  "include": ["/api/*"],
  "exclude": []
}

このファイルは dist/ にコピーする必要があるため、デプロイスクリプトにコピー処理を追加した。


useContactForm composable

フォームの状態管理

フォームの状態管理、バリデーション、送信処理を1つの composable にまとめた。別プロジェクト(schliemann)の contact.vue を参考に、ページコンポーネントをシンプルに保つ設計にしている。

// app/composables/useContactForm.ts
import { useVuelidate } from '@vuelidate/core'
import { required, email, helpers } from '@vuelidate/validators'

interface ContactFormState {
  name: string
  email: string
  company: string
  phone: string
  message: string
}

export const useContactForm = (industry: Ref<string>) => {
  const formState = reactive<ContactFormState>({
    name: '',
    email: '',
    company: '',
    phone: '',
    message: '',
  })

  // バリデーションルール
  const rules = {
    name: {
      required: helpers.withMessage('お名前を入力してください', required),
    },
    email: {
      required: helpers.withMessage('メールアドレスを入力してください', required),
      email: helpers.withMessage('正しいメールアドレスを入力してください', email),
    },
    message: {
      required: helpers.withMessage('お問い合わせ内容を入力してください', required),
    },
  }

  const v$ = useVuelidate(rules, formState)

  const isSubmitting = ref(false)
  const isSubmitted = ref(false)
  const submitError = ref<string | null>(null)

  const submitForm = async () => {
    const isValid = await v$.value.$validate()
    if (!isValid) return

    isSubmitting.value = true
    submitError.value = null

    try {
      const response = await fetch('/api/contact', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          ...formState,
          industry: industry.value,
        }),
      })

      if (!response.ok) {
        const data = await response.json()
        throw new Error(data.error || '送信に失敗しました')
      }

      isSubmitted.value = true
    } catch (error) {
      submitError.value = error instanceof Error
        ? error.message
        : '送信に失敗しました。時間をおいて再度お試しください。'
    } finally {
      isSubmitting.value = false
    }
  }

  return {
    formState,
    v$,
    isSubmitting,
    isSubmitted,
    submitError,
    submitForm,
  }
}

Vuelidate によるバリデーション

バリデーションには Vuelidate を使った。helpers.withMessage で日本語のエラーメッセージをカスタマイズしている。

必須チェックの対象は以下の3フィールド。

  • お名前 (name): 必須
  • メールアドレス (email): 必須 + メール形式チェック
  • お問い合わせ内容 (message): 必須

会社名と電話番号は任意入力にした。お問い合わせのハードルを下げるため。

送信状態の管理

isSubmittingisSubmittedsubmitError の3つの ref で送信状態を管理する。送信中はボタンを無効化し、送信完了後は完了画面を表示する。エラーが発生した場合はエラーメッセージを表示する。


ContactForm.vue コンポーネント

フォーム UI

共通コンポーネントとして lp/shared/ContactForm.vue に配置した。全バリエーションで同じフォームを使い回す。

<!-- app/components/lp/shared/ContactForm.vue -->
<script setup lang="ts">
interface Props {
  industry: string
  industryLabel: string
  themeColor?: string
}

const props = withDefaults(defineProps<Props>(), {
  themeColor: '#1a56db',
})

const industryRef = computed(() => props.industry)
const {
  formState,
  v$,
  isSubmitting,
  isSubmitted,
  submitError,
  submitForm,
} = useContactForm(industryRef)
</script>

<template>
  <!-- 送信完了画面 -->
  <div v-if="isSubmitted" class="text-center py-12">
    <div class="text-5xl mb-4">&#10003;</div>
    <h2 class="text-2xl font-bold mb-2">
      お問い合わせありがとうございます
    </h2>
    <p class="text-gray-600">
      内容を確認の上、2営業日以内にご返信いたします。
    </p>
  </div>

  <!-- フォーム -->
  <form v-else @submit.prevent="submitForm" class="space-y-6">
    <!-- お名前 -->
    <div>
      <label class="block text-sm font-medium text-gray-700 mb-1">
        お名前 <span class="text-red-500">*</span>
      </label>
      <input
        v-model="formState.name"
        type="text"
        class="w-full px-4 py-3 border rounded-lg focus:ring-2 focus:outline-none"
        :class="v$.name.$error ? 'border-red-500' : 'border-gray-300'"
        placeholder="山田 太郎"
      />
      <p v-if="v$.name.$error" class="mt-1 text-sm text-red-500">
        {{ v$.name.$errors[0].$message }}
      </p>
    </div>

    <!-- メールアドレス -->
    <div>
      <label class="block text-sm font-medium text-gray-700 mb-1">
        メールアドレス <span class="text-red-500">*</span>
      </label>
      <input
        v-model="formState.email"
        type="email"
        class="w-full px-4 py-3 border rounded-lg focus:ring-2 focus:outline-none"
        :class="v$.email.$error ? 'border-red-500' : 'border-gray-300'"
        placeholder="[email protected]"
      />
      <p v-if="v$.email.$error" class="mt-1 text-sm text-red-500">
        {{ v$.email.$errors[0].$message }}
      </p>
    </div>

    <!-- 会社名(任意) -->
    <div>
      <label class="block text-sm font-medium text-gray-700 mb-1">
        会社名・屋号
      </label>
      <input
        v-model="formState.company"
        type="text"
        class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:outline-none"
        placeholder="株式会社○○ / ○○サロン"
      />
    </div>

    <!-- 電話番号(任意) -->
    <div>
      <label class="block text-sm font-medium text-gray-700 mb-1">
        電話番号
      </label>
      <input
        v-model="formState.phone"
        type="tel"
        class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:outline-none"
        placeholder="090-1234-5678"
      />
    </div>

    <!-- お問い合わせ内容 -->
    <div>
      <label class="block text-sm font-medium text-gray-700 mb-1">
        お問い合わせ内容 <span class="text-red-500">*</span>
      </label>
      <textarea
        v-model="formState.message"
        rows="6"
        class="w-full px-4 py-3 border rounded-lg focus:ring-2 focus:outline-none"
        :class="v$.message.$error ? 'border-red-500' : 'border-gray-300'"
        placeholder="ご相談内容をご記入ください"
      />
      <p v-if="v$.message.$error" class="mt-1 text-sm text-red-500">
        {{ v$.message.$errors[0].$message }}
      </p>
    </div>

    <!-- エラーメッセージ -->
    <div v-if="submitError" class="p-4 bg-red-50 border border-red-200 rounded-lg">
      <p class="text-red-600 text-sm">{{ submitError }}</p>
    </div>

    <!-- 送信ボタン -->
    <button
      type="submit"
      :disabled="isSubmitting"
      class="w-full py-4 text-white font-bold rounded-lg transition-colors"
      :style="{ backgroundColor: themeColor }"
    >
      {{ isSubmitting ? '送信中...' : '送信する' }}
    </button>
  </form>
</template>

バリエーション別テーマカラー対応

themeColor prop で各LP のアクセントカラーを受け取り、送信ボタンの背景色に反映する。各LP のデザインとの統一感を出すため。


contact.vue ページ

テンプレート切替対応

お問い合わせページはバリエーションによってデザインテンプレートが異なる。useSubdomain で取得した情報をもとに、LP のテンプレートに合わせたスタイリングを適用する。

<!-- app/pages/contact.vue -->
<script setup lang="ts">
const subdomain = useSubdomain()
const { industryConfig } = useIndustryConfig(subdomain)

useHead({
  title: `お問い合わせ | ${industryConfig.value.label}`,
})
</script>

<template>
  <div>
    <LpHeader />
    <main class="max-w-2xl mx-auto px-4 py-12">
      <h1 class="text-3xl font-bold mb-2">お問い合わせ</h1>
      <p class="text-gray-600 mb-8">
        {{ industryConfig.label }}に関するご質問・ご相談はお気軽にどうぞ。
      </p>

      <LpContactForm
        :industry="subdomain"
        :industry-label="industryConfig.label"
        :theme-color="industryConfig.themeColor"
      />
    </main>
    <LpFooter />
  </div>
</template>

ポイントは、フォームコンポーネントにバリエーション情報を渡しているところ。メール送信時に「どのLPからの問い合わせか」がわかるようにしている。受信したメールの件名にバリエーション名が入るため、どのサブドメインからの問い合わせか一目で判別できる。


送信元ドメインの問題と解決

最初のエラー: ドメイン未認証

最初のテスト送信で 500 エラーが返ってきた。Resend API のレスポンスを見ると、送信元ドメインが認証されていないというエラーだった。

{
  "statusCode": 403,
  "message": "The domain example.com is not verified. Please verify your domain before sending emails."
}

原因の調査

Resend Dashboard で認証済みドメインを確認すると、ルートドメインではなく no-reply.example.com のようなサブドメインが認証済みだった。サブドメインで認証している場合、そのサブドメインのアドレスからしか送信できない。

修正

送信元アドレスを変更した。

// 修正前(エラー)
from: 'お問い合わせフォーム <[email protected]>'

// 修正後(成功)
from: 'お問い合わせフォーム <[email protected]>'

wrangler pages devfunctions/ ディレクトリのファイル変更を自動検知してリコンパイルするため、ファイルを保存するだけで修正が反映された。


受信先の問題と解決

バウンスエラー

送信元の問題を解決した後、API は 200 OK を返すようになった。しかし Resend Dashboard でメールの状態を確認すると「Bounced」になっていた。

最初の受信先はタイプミスで存在しないアドレスだった。受信側のメールサーバーが「Recipient not found」を返し、バウンスしていた。

正しい受信先の設定

正しいアドレス [email protected] に修正した。

Gmail でのテスト確認

まず確実に届く別のアドレスで検証するため、テスト用 Gmail を受信先にしてテスト送信した。Gmail の受信トレイにメールが届いたのを確認してから、本番の受信先に変更した。


全バリエーションでの送信テスト

wrangler pages dev でのテスト

nuxt generate で SSG ビルドした dist/wrangler pages dev で配信し、実際のデプロイ構成に近い環境でテストした。wrangler pages dev.dev.vars から RESEND_API_KEY を自動で読み込む。

# SSG ビルド
pnpm generate

# _routes.json を dist にコピー
cp _routes.json dist/

# wrangler pages dev で起動
npx wrangler pages dev dist --port 8788

テスト結果

全バリエーションでフォーム送信を実行し、Resend API から 200 OK が返ることを確認した。

バリエーションサブドメインステータス
LP-Asubdomain-a200 OK
LP-Bsubdomain-b200 OK
LP-Csubdomain-c200 OK
LP-Dsubdomain-d200 OK
LP-Esubdomain-e200 OK

メールの件名にはバリエーション名が含まれるため、受信側でどの LP からの問い合わせかすぐに判別できる。


ヘッダー・CTAの導線設定

既存コンポーネントにお問い合わせページへの導線を追加した。

Header.vue

ナビゲーションに「お問い合わせ」リンクを追加。全バリエーションのヘッダーに表示される。

Cta.vue

LP 内の CTA(Call To Action)ボタンのリンク先を /contact に設定。「無料相談はこちら」ボタンをクリックすると、お問い合わせページに遷移する。


残課題

文字化けの問題

テスト送信時に日本語の文字化けが発生した。curl コマンドで直接 API を叩いた場合に顕著で、Windows の Git Bash から UTF-8 の日本語を送ると正しくエンコードされないケースがある。ブラウザのフォームからの送信では再現していないが、受信メール側でも文字化けが起きている可能性があるため、翌日に調査する予定。

HTML メールのエンコーディング指定や、Content-Type ヘッダーへの charset 追加が解決策の候補。

Cloudflare シークレットの本番設定

ローカル開発では .dev.vars に API キーを置いているが、本番デプロイ時は Cloudflare Dashboard から各 Pages プロジェクトにシークレットを登録する必要がある。

# CLI での設定方法
npx wrangler pages secret put RESEND_API_KEY --project-name=my-lp-a
npx wrangler pages secret put RESEND_API_KEY --project-name=my-lp-b
# ... 各サブドメインのプロジェクトに対して実行

または Dashboard の「Settings > Environment variables > Production」から GUI で設定する。

.wrangler ディレクトリの除外

wrangler pages dev を実行すると .wrangler/ ディレクトリにテンポラリファイルが生成される。Git 管理の対象外にするため .gitignore に追加した。


今日の構成まとめ

tax-lp/
├── functions/
│   └── api/
│       └── contact.ts          # Resend API を呼ぶ Pages Function
├── app/
│   ├── composables/
│   │   └── useContactForm.ts   # フォーム状態管理・バリデーション・送信
│   ├── components/
│   │   └── lp/
│   │       └── shared/
│   │           └── ContactForm.vue  # フォーム UI(全バリエーション共通)
│   └── pages/
│       └── contact.vue         # お問い合わせページ(テンプレート切替対応)
├── .dev.vars                   # ローカル開発用環境変数
├── _routes.json                # Functions ルーティング設定
└── dist/                       # SSG ビルド出力

データの流れ

[ブラウザ]
  ↓ フォーム入力
[contact.vue]
  ↓ ContactForm.vue にデータを渡す
[useContactForm]
  ↓ Vuelidate でバリデーション
  ↓ fetch('/api/contact', { method: 'POST', body: ... })
[Cloudflare Pages Functions]
  ↓ functions/api/contact.ts が受け取る
  ↓ context.env.RESEND_API_KEY を使って
[Resend API]
  ↓ メール送信
[[email protected]]

SSG サイトでありながら、functions/ に1ファイル置くだけでバックエンド API が使える。Cloudflare Pages Functions の手軽さが光る構成になった。