# バックエンドなしで静的サイトにお問い合わせフォームを追加する

> サーバーなしで静的サイトに動くお問い合わせフォームを追加します。POST の送り先、スパム、メールでの返信、各ホストとフレームワーク向けのコードまで。

公開日 2026-10-25 · https://ikiform.com/ja/guides/contact-form-static-site

静的ホストはファイルを配信するだけで、フォームの POST はたいてい `405` か `404` で拒否するので、フォームは別の場所に送信する必要があります。フォームの `action` を Ikiform などのフォームバックエンドに向ければ、メッセージごとに保存し、スパムを除外し、送信者を reply-to にしたメールで通知します。サイトは静的なままで、サーバーのコードも、ページ内のシークレットキーも、デプロイ方法の変更も要りません。

静的サイトとは、ホストがファイルとして配信する HTML、CSS、JavaScript のフォルダーです。だから速く、安く、壊れにくいのです。そしてそれこそが、お問い合わせフォームが真っ先に動かなくなる理由でもあります。フォームの送信は誰かが受け取るべきリクエストですが、静的ホストには待ち受けているものが何もありません。

## 静的ホストでフォームが失敗する理由

`action` のない `<form method="post">` は、そのフォームがあるページ自身に送信します。静的ホストではそれはファイルで、ファイルが応答するのは `GET` と `HEAD` だけです。2026-10-25 に主要な各ホストで公開中の静的ページに `POST` を送ってみましたが、どれも何も保存しませんでした:

| ホスト | 静的ページへの POST の結果 |
| --- | --- |
| GitHub Pages | 小さな HTML ページで `405 Not Allowed` |
| Vercel | 空のボディで `405` |
| Netlify | `404`。サイトに 404 ページがあればそれを表示。ただし Netlify Forms がデプロイ時にそのフォームを検出した場合を除く |
| Cloudflare Pages | 空のボディで `405` |
| Amazon S3 のウェブサイトエンドポイント | `405 Method Not Allowed`、コードは `MethodNotAllowed` |

解決策はどこでも同じで、送信を受け付ける URL に送ることです。その URL は、自分で書いてホストするコードでも、ホストの機能でも、フォームバックエンドでも構いません。このガイドではこの先、サイトを静的なまま保てるフォームバックエンドを使います。

図: 修正の前後で、お問い合わせフォームの送信がどこへ行くか

1. action がないか、自分のサイトのパスを指す action だと、ブラウザーは静的ホストに送信します。
2. ホストには POST に対して実行するコードがないので、405 か 404 を返し、メッセージは失われます。
3. action を Ikiform のエンドポイントに向けると、送信はそれを受け付けるサーバーに届きます。
4. Ikiform はメッセージのスパムチェックを行い、保存して、メールで通知します。
5. Ikiform は 303 リダイレクトで応答し、ブラウザーは自分のサイトのサンクスページを読み込みます。

変わるのはフォームの action だけです。ページ、ホスト、デプロイはそのままです。

## 各メッセージに起きること

Ikiform はフォームの送信先のエンドポイントです。ページをホストしたりフォームをレンダリングしたりはせず、どちらも自分で持ちます。送信ごとにスパムチェックを行い、メッセージを保存し、あなたが読む場所に届けます。

図: お問い合わせフォームの 1 件のメッセージを Ikiform がどう扱うか

1. 訪問者がお問い合わせフォームに入力し、フォームが Ikiform のエンドポイントに送信されます。
2. Ikiform はハニーポット、レート制限、スパムスコアを確認してから、メッセージを保存します。
3. 通知の受信者それぞれに、reply-to を送信者のアドレスにしたメールが届きます。
4. メッセージはダッシュボードに表示され、検索、スパムの復元、CSV のエクスポートができます。
5. Webhook を追加すると、Ikiform は署名付きのコピーを Slack、Discord、自動化ツール、自分のコードに送ります。

ほとんどのお問い合わせフォームはメールで十分です。いつも作業している場所にメッセージを届けたいときに Webhook を追加してください。

## 静的サイトのフォームを受け取る 5 つの方法

| 方法 | 得意なこと | トレードオフ |
| --- | --- | --- |
| `mailto:` リンクまたは `action="mailto:..."` | 設定は一切不要。アクセスの少ない個人ページなら十分です。 | 訪問者のメールアプリを開きます（設定されていれば）。何も保存もフィルターもされず、アドレスが HTML に載るのでスクレイパーに拾われます。 |
| [Netlify Forms](https://ikiform.com/alternatives/netlify-forms) などのホストのフォーム機能 | 属性 1 つで済み、別のアカウントは不要。たいていホスティングのプランに含まれています。 | そのホストでしか動きません。ホストを移るとフォームを作り直すことになり、JavaScript でレンダリングするフォームには回避策が必要です。 |
| 自分で書くサーバーレス関数 | バリデーション、保存、送るメールを完全に制御できます。 | バックエンドを抱えることになります。メール API のキー、スパム対策、レート制限、リトライ、ログ。関数の形式はホストごとに異なります。 |
| [Google Forms](https://ikiform.com/alternatives/google-forms)、[Tally](https://ikiform.com/alternatives/tally)、[Typeform](https://ikiform.com/alternatives/typeform) などのホスト型フォームの埋め込み | コード不要。長いフォームや複数ステップのフォームに向いています。 | iframe か別サイトへのリンクになり、相手の見た目とスクリプトがページに入ります。 |
| Ikiform、[Formspree](https://ikiform.com/alternatives/formspree)、[Basin](https://ikiform.com/alternatives/basin)、[Web3Forms](https://ikiform.com/alternatives/web3forms) などのフォームバックエンド | どのホストでも自分の HTML を使えます。サーバーなしで保存、スパム対策、メール通知ができます。 | アカウントと請求がもう 1 つ増え、フォームのデータはそのサービスに置かれます。 |

Netlify を使っていて移る予定がないなら、手間の少なさで Netlify Forms にかなうものはありません。バックエンドを自分で書きたいなら、関数は週末のプロジェクトにちょうどよい題材です。ホストやフレームワークを変えてもフォームを動かし続けたいなら、フォームバックエンドを使ってください。各バックエンドは[比較ページ](https://ikiform.com/alternatives)で並べて比べられます。

## フォーム

以下のサンプルはすべてブラウザーから送信します。静的デプロイには送信元になるサーバーがないからです。スタックを選んでください。選択はこのガイドのすべてのコードサンプルに引き継がれます。静的サイトジェネレーターには個別のセットアップページがあります: [Jekyll](https://ikiform.com/ja/docs/guides/jekyll.md)、[Hugo](https://ikiform.com/ja/docs/guides/hugo.md)、[Eleventy](https://ikiform.com/ja/docs/guides/eleventy.md)、[Gatsby](https://ikiform.com/ja/docs/guides/gatsby.md)。

**HTML**

手書きのサイトなら、連携はこれで全部です。フォームは通常の方法で送信するので JavaScript がオフでも動き、送信に成功するとブラウザーは `thanks.html` に移動します。

`contact.html`:

```html
<form id="contact" action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="post">
  <label for="name">Name</label>
  <input id="name" name="name" autocomplete="name" maxlength="120" required />
  <p class="field-error" data-error-for="name" hidden></p>

  <label for="email">Email</label>
  <input id="email" name="email" type="email" autocomplete="email" required />
  <p class="field-error" data-error-for="email" hidden></p>

  <label for="message">Message</label>
  <textarea id="message" name="message" rows="6" minlength="10" maxlength="5000" required></textarea>
  <p class="field-error" data-error-for="message" hidden></p>

  <input type="hidden" name="_redirect" value="https://example.com/thanks.html" />
  <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
  <button type="submit">Send message</button>
  <p id="contact-status" role="status"></p>
</form>
<p id="contact-sent" role="status" hidden>Thanks. Your message is on its way, and I'll reply by email.</p>

<script src="/contact.js" defer></script>
```

スクリプトは任意です。訪問者をページにとどめ、各フィールドのエラーを入力欄の下に表示し、失敗したときも入力したメッセージを残します。

`contact.js`:

```js
const form = document.querySelector("#contact");
const status = document.querySelector("#contact-status");
const done = document.querySelector("#contact-sent");
const button = form.querySelector("button");

function showErrors(fields = {}) {
  for (const slot of form.querySelectorAll("[data-error-for]")) {
    const name = slot.dataset.errorFor;
    const message = fields[name]?.[0] ?? "";
    slot.textContent = message;
    slot.hidden = !message;
    form.elements[name]?.setAttribute("aria-invalid", message ? "true" : "false");
  }
}

form.addEventListener("submit", async (event) => {
  event.preventDefault();
  button.disabled = true;
  status.textContent = "Sending";
  try {
    const response = await fetch(form.action, {
      method: "POST",
      headers: { Accept: "application/json" },
      body: new FormData(form),
    });
    const result = await response.json();
    if (result.ok) {
      form.hidden = true;
      done.hidden = false;
      return;
    }
    showErrors(result.error.fields);
    status.textContent = result.error.message;
  } catch {
    status.textContent = "Couldn't send. Check your connection and try again.";
  }
  button.disabled = false;
});
```

**Next.js**

`output: "export"` を指定すると、`next build` は `out/` にプレーンなファイルを書き出します。リクエストを読む Server Actions や Route Handlers は静的エクスポートでは使えないので、フォームは代わりにブラウザーから送信します。

`next.config.ts`:

```ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  output: "export",
};

export default nextConfig;
```

`app/contact/contact-form.tsx`:

```tsx
"use client";

import { type FormEvent, useState } from "react";

const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";

type Field = "name" | "email" | "message";
type Errors = Partial<Record<Field, string>>;

export function ContactForm() {
  const [sentTo, setSentTo] = useState("");
  const [sending, setSending] = useState(false);
  const [errors, setErrors] = useState<Errors>({});
  const [notice, setNotice] = useState("");

  async function onSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    const body = new FormData(event.currentTarget);
    setSending(true);
    setNotice("");
    try {
      const response = await fetch(ENDPOINT, { method: "POST", headers: { Accept: "application/json" }, body });
      const result = await response.json();
      if (result.ok) {
        setSentTo(String(body.get("email")));
        return;
      }
      const fields = result.error.fields ?? {};
      setErrors({ name: fields.name?.[0], email: fields.email?.[0], message: fields.message?.[0] });
      setNotice(result.error.message);
    } catch {
      setNotice("Couldn't send. Check your connection and try again.");
    } finally {
      setSending(false);
    }
  }

  if (sentTo) return <p role="status">Thanks. I'll reply to {sentTo}.</p>;

  return (
    <form action={ENDPOINT} method="post" onSubmit={onSubmit}>
      <label htmlFor="name">Name</label>
      <input id="name" name="name" autoComplete="name" maxLength={120} required aria-invalid={!!errors.name} />
      {errors.name ? <p>{errors.name}</p> : null}
      <label htmlFor="email">Email</label>
      <input id="email" name="email" type="email" autoComplete="email" required aria-invalid={!!errors.email} />
      {errors.email ? <p>{errors.email}</p> : null}
      <label htmlFor="message">Message</label>
      <textarea id="message" name="message" rows={6} minLength={10} required aria-invalid={!!errors.message} />
      {errors.message ? <p>{errors.message}</p> : null}
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      {notice ? <p role="alert">{notice}</p> : null}
      <button disabled={sending}>{sending ? "Sending" : "Send message"}</button>
    </form>
  );
}
```

`app/contact/page.tsx` から `<ContactForm />` をレンダリングします。フォームは `action` を持ったままなので、JavaScript のバンドルが読み込まれる前でも送信できます。

**Astro**

Astro はデフォルトですべてのページをプリレンダーします。静的ホストにはそれが好都合です。ここで Astro Actions は使わないでください。フォームを Action に送信するには、オンデマンドレンダリングとアダプターが必要になります。Ikiform に送信し、バンドルされる `<script>` で訪問者をページにとどめましょう。

`src/pages/contact.astro`:

```astro
---
import Layout from "../layouts/Layout.astro";

const endpoint = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";
---

<Layout title="Contact">
  <form id="contact" action={endpoint} method="post">
    <label for="name">Name</label>
    <input id="name" name="name" autocomplete="name" required />
    <label for="email">Email</label>
    <input id="email" name="email" type="email" autocomplete="email" required />
    <label for="message">Message</label>
    <textarea id="message" name="message" rows="6" minlength="10" required></textarea>
    <input type="hidden" name="_redirect" value={new URL("/thanks/", Astro.site).href} />
    <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
    <button>Send message</button>
    <output id="contact-status" role="status"></output>
  </form>
</Layout>

<script>
  const form = document.querySelector<HTMLFormElement>("#contact")!;
  const status = document.querySelector<HTMLOutputElement>("#contact-status")!;

  form.addEventListener("submit", async (event) => {
    event.preventDefault();
    status.value = "Sending";
    const response = await fetch(form.action, {
      method: "POST",
      headers: { Accept: "application/json" },
      body: new FormData(form),
    }).catch(() => null);
    const result = await response?.json().catch(() => null);
    if (result?.ok) {
      form.replaceWith(Object.assign(document.createElement("p"), { textContent: "Thanks. Your message is on its way, and I'll reply by email." }));
      return;
    }
    const fields: Record<string, string[]> = result?.error?.fields ?? {};
    const first = Object.entries(fields)[0];
    status.value = first ? `${first[0]}: ${first[1][0]}` : (result?.error?.message ?? "Couldn't send. Check your connection and try again.");
  });
</script>
```

`Astro.site` は `astro.config.mjs` の `site` から来ます。`_redirect` の値が Ikiform がたどれる完全な URL になるよう、自分の `https:` のアドレスを設定してください。

**TanStack Start**

SPA モードでは、どの静的ホストでも配信できるクライアントレンダリングのシェルがビルドされます。サーバー関数にはやはりサーバーが必要なので静的デプロイでは実行できず、ルートはブラウザーから送信して、成功したら `/thanks` に移動します。

`vite.config.ts`:

```ts
import { tanstackStart } from "@tanstack/react-start/plugin/vite";
import viteReact from "@vitejs/plugin-react";
import { defineConfig } from "vite";

export default defineConfig({
  plugins: [
    tanstackStart({
      spa: {
        enabled: true,
      },
    }),
    viteReact(),
  ],
});
```

`src/routes/contact.tsx`:

```tsx
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { type FormEvent, useState } from "react";

const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";

export const Route = createFileRoute("/contact")({ component: Contact });

function Contact() {
  const navigate = useNavigate();
  const [problems, setProblems] = useState<string[]>([]);
  const [sending, setSending] = useState(false);

  async function onSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    setSending(true);
    try {
      const response = await fetch(ENDPOINT, {
        method: "POST",
        headers: { Accept: "application/json" },
        body: new FormData(event.currentTarget),
      });
      const result = await response.json();
      if (result.ok) {
        await navigate({ to: "/thanks" });
        return;
      }
      const fields: Record<string, string[]> = result.error.fields ?? {};
      setProblems(Object.keys(fields).length ? Object.values(fields).map((list) => list[0]) : [result.error.message]);
    } catch {
      setProblems(["Couldn't send. Check your connection and try again."]);
    }
    setSending(false);
  }

  return (
    <form action={ENDPOINT} method="post" onSubmit={onSubmit}>
      {problems.length ? (
        <ul role="alert">
          {problems.map((problem) => (
            <li key={problem}>{problem}</li>
          ))}
        </ul>
      ) : null}
      <label htmlFor="name">Name</label>
      <input id="name" name="name" autoComplete="name" required />
      <label htmlFor="email">Email</label>
      <input id="email" name="email" type="email" autoComplete="email" required />
      <label htmlFor="message">Message</label>
      <textarea id="message" name="message" rows={6} minLength={10} required />
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      <button disabled={sending}>{sending ? "Sending" : "Send message"}</button>
    </form>
  );
}
```

**React Router**

`ssr: false` ではサーバー用のビルドがないので、ルートにサーバーの `action` を持てません。代わりにブラウザーで `clientAction` が動き、`<Form>` の送信を Ikiform に転送して、成功したらリダイレクトします。

`react-router.config.ts`:

```ts
import type { Config } from "@react-router/dev/config";

export default {
  ssr: false,
} satisfies Config;
```

`app/routes/contact.tsx`:

```tsx
import { Form, redirect, useNavigation } from "react-router";
import type { Route } from "./+types/contact";

const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";

export async function clientAction({ request }: Route.ClientActionArgs) {
  const response = await fetch(ENDPOINT, {
    method: "POST",
    headers: { Accept: "application/json" },
    body: await request.formData(),
  });
  const result = await response.json();
  if (result.ok) return redirect("/thanks");
  return {
    fields: (result.error.fields ?? {}) as Record<string, string[]>,
    message: String(result.error.message),
  };
}

export default function Contact({ actionData }: Route.ComponentProps) {
  const sending = useNavigation().state === "submitting";
  const error = (name: string) => actionData?.fields[name]?.[0];

  return (
    <Form method="post">
      <label htmlFor="name">Name</label>
      <input id="name" name="name" autoComplete="name" required />
      {error("name") ? <p>{error("name")}</p> : null}
      <label htmlFor="email">Email</label>
      <input id="email" name="email" type="email" autoComplete="email" required />
      {error("email") ? <p>{error("email")}</p> : null}
      <label htmlFor="message">Message</label>
      <textarea id="message" name="message" rows={6} minLength={10} required />
      {error("message") ? <p>{error("message")}</p> : null}
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      {actionData?.message ? <p role="alert">{actionData.message}</p> : null}
      <button disabled={sending}>{sending ? "Sending" : "Send message"}</button>
    </Form>
  );
}
```

**SvelteKit**

`adapter-static` と `prerender = true` では、すべてのページがファイルになります。SvelteKit のフォーム Action は使えません。Action のあるページは、サーバーが POST を処理する必要があるためプリレンダーできないからです。代わりにコンポーネントが `fetch` で送信します。

`src/routes/+layout.ts`:

```ts
export const prerender = true;
```

`src/routes/contact/+page.svelte`:

```svelte
<script lang="ts">
  const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";

  let phase = $state<"idle" | "sending" | "sent">("idle");
  let errors = $state<Record<string, string[]>>({});
  let notice = $state("");

  async function send(event: SubmitEvent) {
    event.preventDefault();
    phase = "sending";
    notice = "";
    try {
      const response = await fetch(ENDPOINT, {
        method: "POST",
        headers: { Accept: "application/json" },
        body: new FormData(event.currentTarget as HTMLFormElement),
      });
      const result = await response.json();
      if (result.ok) {
        phase = "sent";
        return;
      }
      errors = result.error.fields ?? {};
      notice = result.error.message;
    } catch {
      notice = "Couldn't send. Check your connection and try again.";
    }
    phase = "idle";
  }
</script>

{#if phase === "sent"}
  <p role="status">Thanks. Your message is on its way, and I'll reply by email.</p>
{:else}
  <form action={ENDPOINT} method="post" onsubmit={send}>
    <label for="name">Name</label>
    <input id="name" name="name" autocomplete="name" required aria-invalid={!!errors.name} />
    {#if errors.name}<p>{errors.name[0]}</p>{/if}
    <label for="email">Email</label>
    <input id="email" name="email" type="email" autocomplete="email" required aria-invalid={!!errors.email} />
    {#if errors.email}<p>{errors.email[0]}</p>{/if}
    <label for="message">Message</label>
    <textarea id="message" name="message" rows="6" minlength="10" required aria-invalid={!!errors.message}></textarea>
    {#if errors.message}<p>{errors.message[0]}</p>{/if}
    <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
    {#if notice}<p role="alert">{notice}</p>{/if}
    <button disabled={phase === "sending"}>{phase === "sending" ? "Sending" : "Send message"}</button>
  </form>
{/if}
```

**Nuxt**

`nuxt generate` はプリレンダーしたページを `.output/public` に書き出します。`server/api` のルートは静的ホストには存在しないので、コンポーネントが `$fetch` で直接 Ikiform を呼び、成功したら `/thanks` に移動します。

`app/pages/contact.vue`:

```vue
<script setup lang="ts">
const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";

const form = reactive({ name: "", email: "", message: "", _gotcha: "" });
const errors = ref<Record<string, string[]>>({});
const notice = ref("");
const sending = ref(false);

async function send() {
  sending.value = true;
  notice.value = "";
  try {
    await $fetch(ENDPOINT, { method: "POST", headers: { Accept: "application/json" }, body: form });
    await navigateTo("/thanks");
  } catch (failure) {
    const data = (failure as { data?: { error?: { message?: string; fields?: Record<string, string[]> } } }).data;
    errors.value = data?.error?.fields ?? {};
    notice.value = data?.error?.message ?? "Couldn't send. Check your connection and try again.";
  } finally {
    sending.value = false;
  }
}
</script>

<template>
  <form :action="ENDPOINT" method="post" @submit.prevent="send">
    <label for="name">Name</label>
    <input id="name" v-model="form.name" name="name" autocomplete="name" required />
    <p v-if="errors.name">{{ errors.name[0] }}</p>
    <label for="email">Email</label>
    <input id="email" v-model="form.email" name="email" type="email" autocomplete="email" required />
    <p v-if="errors.email">{{ errors.email[0] }}</p>
    <label for="message">Message</label>
    <textarea id="message" v-model="form.message" name="message" rows="6" minlength="10" required />
    <p v-if="errors.message">{{ errors.message[0] }}</p>
    <input v-model="form._gotcha" name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
    <p v-if="notice" role="alert">{{ notice }}</p>
    <button :disabled="sending">{{ sending ? "Sending" : "Send message" }}</button>
  </form>
</template>
```

**React**

静的ホスト上の Vite のシングルページアプリです。エンドポイントは `VITE_` の変数に置くので、プレビューと本番のビルドで別のフォームを指せます。どちらにしてもバンドルに含まれますが、シークレットではないので問題ありません。

`.env.production`:

```text
VITE_IKIFORM_ENDPOINT=https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx
```

`src/ContactForm.tsx`:

```tsx
import { type FormEvent, useState } from "react";

const ENDPOINT = import.meta.env.VITE_IKIFORM_ENDPOINT;

type Status = { kind: "idle" } | { kind: "sending" } | { kind: "sent" } | { kind: "failed"; lines: string[] };

export function ContactForm() {
  const [status, setStatus] = useState<Status>({ kind: "idle" });

  async function onSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    const body = new FormData(event.currentTarget);
    setStatus({ kind: "sending" });
    try {
      const response = await fetch(ENDPOINT, { method: "POST", headers: { Accept: "application/json" }, body });
      const result = await response.json();
      if (result.ok) return setStatus({ kind: "sent" });
      const fields: Record<string, string[]> = result.error.fields ?? {};
      const lines = Object.entries(fields).map(([name, list]) => `${name}: ${list[0]}`);
      setStatus({ kind: "failed", lines: lines.length ? lines : [result.error.message] });
    } catch {
      setStatus({ kind: "failed", lines: ["Couldn't send. Check your connection and try again."] });
    }
  }

  if (status.kind === "sent") return <p role="status">Thanks. Your message is on its way, and I'll reply by email.</p>;

  return (
    <form action={ENDPOINT} method="post" onSubmit={onSubmit}>
      <label htmlFor="name">Name</label>
      <input id="name" name="name" autoComplete="name" required />
      <label htmlFor="email">Email</label>
      <input id="email" name="email" type="email" autoComplete="email" required />
      <label htmlFor="message">Message</label>
      <textarea id="message" name="message" rows={6} minLength={10} required />
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      {status.kind === "failed" ? (
        <div role="alert">
          {status.lines.map((line) => (
            <p key={line}>{line}</p>
          ))}
        </div>
      ) : null}
      <button disabled={status.kind === "sending"}>{status.kind === "sending" ? "Sending" : "Send message"}</button>
    </form>
  );
}
```

**Vue**

同じブラウザーからの送信を Vue のシングルファイルコンポーネントで書き、同じ `VITE_` の変数からエンドポイントを読みます。表示するのは最初のエラーだけで、ボタンの横に出します。

`src/components/ContactForm.vue`:

```vue
<script setup lang="ts">
import { computed, ref } from "vue";

const ENDPOINT = import.meta.env.VITE_IKIFORM_ENDPOINT;

const sending = ref(false);
const sent = ref(false);
const fields = ref<Record<string, string[]>>({});
const notice = ref("");
const firstError = computed(() => Object.values(fields.value)[0]?.[0] ?? notice.value);

async function send(event: SubmitEvent) {
  sending.value = true;
  try {
    const response = await fetch(ENDPOINT, {
      method: "POST",
      headers: { Accept: "application/json" },
      body: new FormData(event.target as HTMLFormElement),
    });
    const result = await response.json();
    sent.value = result.ok;
    fields.value = result.ok ? {} : (result.error.fields ?? {});
    notice.value = result.ok ? "" : result.error.message;
  } catch {
    notice.value = "Couldn't send. Check your connection and try again.";
  } finally {
    sending.value = false;
  }
}
</script>

<template>
  <p v-if="sent" role="status">Thanks. Your message is on its way, and I'll reply by email.</p>
  <form v-else :action="ENDPOINT" method="post" @submit.prevent="send">
    <label for="name">Name</label>
    <input id="name" name="name" autocomplete="name" required :aria-invalid="!!fields.name" />
    <label for="email">Email</label>
    <input id="email" name="email" type="email" autocomplete="email" required :aria-invalid="!!fields.email" />
    <label for="message">Message</label>
    <textarea id="message" name="message" rows="6" minlength="10" required :aria-invalid="!!fields.message" />
    <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
    <p v-if="firstError" role="alert">{{ firstError }}</p>
    <button :disabled="sending">{{ sending ? "Sending" : "Send message" }}</button>
  </form>
</template>
```

- `frm_xxxxxxxxxxxxxxxx` をダッシュボードにある自分のフォームの ID に置き換えてください。ID はもともと公開される前提のもので、デプロイした瞬間に HTML に含まれます。静的サイトにシークレットの API キーを置くことは絶対にしないでください。
- メールアドレスのフィールド名は `email` のままにしてください。Ikiform はこれを通知メールの reply-to に設定し、使い捨てメールのドメインと照合します。
- 非表示の `_gotcha` 入力欄はハニーポットです。人には見えず、すべての入力欄を埋めるボットは自らスパムだと名乗ることになります。
- JavaScript 版は `Accept: application/json` を送るので、Ikiform は成功すると `201` を、失敗すると各フィールドと問題を示す `error.fields` オブジェクト付きの `422` を返します。

本番でフレームワークがサーバー上で動くなら、代わりにそこから送信して、エンドポイントをページに出さないこともできます。サーバー版は [Next.js](https://ikiform.com/ja/docs/guides/nextjs.md)、[SvelteKit](https://ikiform.com/ja/docs/guides/sveltekit.md)、[Nuxt](https://ikiform.com/ja/docs/guides/nuxt.md) の各ガイドに、シークレットキーについては[サーバーから送信する](https://ikiform.com/ja/docs/guides/server.md)にあります。

> サーバー側の版はシークレットキー付きで送信するので、Ikiform は **Allowed domains** と人間確認をスキップします。その場合はすべての送信がサーバーの IP から来るため、訪問者ごとに 1 分あたり 5 件という Standard の制限が、全訪問者をまとめて数えることになります。サーバー経由で送信するフォームには Relaxed か Custom の[レート制限](https://ikiform.com/ja/docs/rate-limits.md)を選び、`IKIFORM_SECRET_KEY` はサーバー専用の環境変数に置いてください。

## お問い合わせフォームのフィールド

名前、メールアドレス、メッセージがあれば、ほとんどのお問い合わせフォームは足ります。メッセージの種類によって対応する人が違うなら、件名の選択欄を追加する価値があります。下のフォームを試してみてください。ブラウザーで Ikiform のバリデーターを実行し、保存される内容か、返ってくる `422` を表示します。

フォーム: Contact

- **名前** (`name`, text, 必須)
- **メールアドレス** (`email`, email, 必須)
- **件名** (`topic`, select)
- **メッセージ** (`message`, textarea, 必須)

[お問い合わせフォームのテンプレート](https://ikiform.com/forms/contact-form)と同じ定義です。フォームの **Fields** タブに貼り付けると、HTML がどうであれ Ikiform がサーバー側でこれを適用します。

| フィールド | 理由 | 注意点 |
| --- | --- | --- |
| `name` | 返信での呼びかけに使う | 名前欄のリンクは強いスパムのサインなので、ID は `name` のままにする |
| `email` | 返信の宛先 | 必須。`type="email"` でスマートフォンに適切なキーボードが出る |
| `topic` | 営業とサポートを適切な担当者に振り分ける | 選択欄は一覧にある値しか受け付けないので、安心して絞り込みに使える |
| `message` | フォームの目的そのもの | 最小 10 文字にすると一言だけの送信を防げる |

電話番号、会社の規模など、最初の返信で使わないものは省いてください。フィールドを増やすほどメッセージは減ります。フィールド定義がなければ、Ikiform はフォームが送る名前付きの入力欄をすべて保存します。定義があれば、合わない送信を拒否します。

> ファイルのアップロードにはまだ対応していません。ファイル付きの送信には `422 files_not_supported` が返ります。

## 受信トレイから返信する

通知メールは、フォームごとに確認済みの受信者最大 10 人に届きます。フォームに `email` フィールドがあればメールの reply-to が送信者になるので、メールアプリで **Reply**（返信）を押せばそのまま相手に返信できます。ダッシュボードからアドレスをコピーする必要はありません。

通知はフォームごとに 1 時間あたり 50 通までで、それを超えると 1 時間ごとのダイジェストになるので、スパムの波で受信トレイが埋もれることはありません。スパムと判定されたメッセージはメールを送りません。`_subject` は通知メールの件名を変えません。Webhook と API には `meta.subject` として渡されます。

> Ikiform はフォームを入力した本人にはメールを送りません。通知メールが届くのはあなたとチームだけなので、自動返信、確認メール、ダブルオプトインはありません。それらは [Webhook](https://ikiform.com/ja/docs/webhooks.md) をきっかけに、自分のメールツールから送ってください。

メッセージをメールの代わりに、またはメールに加えてチームのチャットで受け取りたいですか？ Webhook を追加して、[Slack](https://ikiform.com/blog/form-submissions-to-slack.md)、[Discord](https://ikiform.com/blog/form-submissions-to-discord.md)、[Telegram](https://ikiform.com/blog/form-submissions-to-telegram.md) の手順に従ってください。

## 送信後に訪問者に表示されるもの

プレーンな HTML の送信はリダイレクトで終わります。Ikiform はブラウザーからの送信が成功すると `303 See Other` で応答し、ブラウザーは移動先を `GET` で読み込みます。これなら静的ホストも喜んで配信します。

図: プレーンな HTML フォームの 1 回の送信の裏にある 3 つのリクエスト

1. ブラウザーがフォームのフィールドを Ikiform のエンドポイントに送信します。
2. Ikiform はメッセージを保存し、Location ヘッダー付きの 303 See Other で応答します。
3. ブラウザーはリダイレクトに従ってサンクスページを GET し、静的ホストはそれを他のファイルと同じように配信します。

リダイレクト先がなければ、手順 3 では自分のページではなく Ikiform がホストするサンクスページが読み込まれます。

Ikiform は次の順番で移動先を選びます:

- フォーム設定の **Redirect after submit**。設定できるのはチームだけなので、`http:` でも `https:` でも任意の URL を使えます。サイトが 1 つならこれがいちばん簡単です。
- フォーム内の `_redirect` フィールド。フォームの **Allowed domains** にある `https:` の URL の場合に限ります。1 つのフォームを複数のサイトや言語で使うときに使います。
- どちらもなければ、Ikiform がホストするサンクスページ。許可された `https:` のページからの送信なら、サイトへ戻るリンクが付きます。

サンクスページは実在するファイルにし、検索結果には出ないようにしてください。いつ返信するかを書いておけば、同じ人から 2 通目のメールが来るのを防げます。

`thanks.html`:

```html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <meta name="robots" content="noindex" />
    <title>Message sent</title>
  </head>
  <body>
    <h1>Thanks, your message is on its way</h1>
    <p>I read every message and reply within two working days, from the address on this site.</p>
    <p><a href="/">Back to the home page</a></p>
  </body>
</html>
```

上の JavaScript 版はリダイレクトを使いません。`Accept: application/json` 付きの送信には JSON が返り、ページが自分で成功のメッセージを表示します。

## 静的なフォームのスパム

エンドポイントはページのソースに載っていて、ボットはページのソースを読みます。公開から数日でスパムが来ると思っておいてください。Ikiform は防御を何層にも重ねているので、設定ゼロから始められます:

| 防御 | 防げるもの | 設定 |
| --- | --- | --- |
| ハニーポット | すべての入力欄を埋めるボット | 非表示の `_gotcha` 入力欄 |
| スパムスコア | 名前欄のリンク（+3）、多数のリンク（+2）、既知のスパム表現（+2）、使い捨てアドレス。5 に達したメッセージはスパムとして保存 | なし |
| レート制限 | ループで送信し続けるスクリプト | デフォルトでオン: Standard、訪問者ごとに 1 分あたり 5 件 |
| 許可ドメイン | 他のサイトから送信される、フォームのコピー | フォーム設定にドメインを追加 |
| 人間確認 | 本物のブラウザーを操作するボット | **Spam check** を **Ikiform** にすると、キー不要で Cloudflare Turnstile を使える |

スパムには本物のメッセージと同じ成功の応答が返るのでボットは何も学べず、メールも Webhook も送られません。お問い合わせフォームで 1 人が数分のうちに 2 回以上送信することはまれなので、公開後は訪問者ごとに 10 分あたり 2 件の Strict が合っています。各層の説明は[スパムのドキュメント](https://ikiform.com/ja/docs/spam.md)にあり、人間確認を使うには JavaScript 版で [SDK](https://ikiform.com/ja/docs/sdk.md) の `challenge()` を 1 回呼ぶ必要があります。

### 許可ドメインとプレビューデプロイ

**Allowed domains** は、ドメインとそのサブドメインに一致します。静的ホストでは思ったより多くのドメインを使います。本番のドメイン、ホスト自身のサブドメイン、そしてブランチやプルリクエストごとのプレビュー用アドレスです。プレビューの中には、サイトのホスト用アドレスのサブドメインで一致するものもあれば、そうでなく個別の登録が必要なものもあります。

| ホスト | 追加するもの | プレビュー |
| --- | --- | --- |
| GitHub Pages | `username.github.io` と独自ドメイン | デフォルトではプレビューなし |
| Vercel | `your-project.vercel.app` と自分のドメイン | ブランチの URL は一致しない。テスト中は正確なホストを追加する |
| Netlify | `your-site.netlify.app` と自分のドメイン | `deploy-preview-12--your-site.netlify.app` は一致しない |
| Cloudflare Pages | `your-project.pages.dev` と自分のドメイン | `abc123.your-project.pages.dev` は最初から一致する |

`github.io`、`vercel.app`、`netlify.app` のような共有ホストだけを追加することは絶対にしないでください。他人のサイトも含め、そのホスト上のすべてのサイトが一致してしまいます。

## サイトが Content-Security-Policy を送っている場合

静的サイトのテンプレートの多くには厳しい `Content-Security-Policy` が含まれています。フォームが動くかどうかを決めるディレクティブは 2 つです。プレーンな HTML の送信には `form-action`、`fetch` 版には `connect-src` です。どちらかが欠けていると、リクエストはページを出る前にブラウザーにブロックされ、手がかりはコンソールのメッセージだけになります。

`レスポンスヘッダー`:

```text
Content-Security-Policy: default-src 'self'; form-action 'self' https://ikiform.com; connect-src 'self' https://ikiform.com
```

`form-action` には `'self'` を残してください。ブラウザーによっては、サンクスページへのリダイレクトもこれと照合するためです。ヘッダーを置く場所はホストによって異なります。Netlify と Cloudflare Pages は `_headers`、Vercel は `vercel.json`、GitHub Pages は `<meta>` タグ、S3 なら前段の CloudFront のレスポンスヘッダーポリシーです。それぞれの方法は下のホスト別ページにあります。

## デプロイ前にテストする

ファイルをダブルクリックするのではなく、ローカルサーバー経由でページを開いてください。`file://` で開いたページは `Origin: null` を送り、どの許可ドメインにも一致せず、ローカルのテストとしても扱われません。次のどれでも現在のフォルダーを配信できます:

`ターミナル`:

```bash
npx serve .

python3 -m http.server 8000
```

テスト中は `localhost` を **Allowed domains** に追加してください。`localhost` からの送信はテストとして扱われ、プランのないワークスペースでも月 50 件まで使えるので、支払いの前にフォーム全体を作り上げられます。

## 状況に合わせて選ぶ

上のフォームはどの静的ホストでも動きます。ホストごとに失敗のしかた、プレビュー用ドメイン、ヘッダーファイルが異なります。それらは次のページで説明しています。

- [GitHub Pages](https://ikiform.com/ja/guides/contact-form-static-site/github-pages.md): GitHub Pages ではサーバーコードが動かず、フォームの POST は 405 Not Allowed になります。フォームバックエンドに送信し、プロジェクトサイトのリダイレクトを正しく設定しましょう。
- [Vercel](https://ikiform.com/ja/guides/contact-form-static-site/vercel.md): Vercel の静的ページはフォームの POST に 405 Method Not Allowed を返します。その理由と、代わりにバックエンドへフォームを送る方法を説明します。
- [Netlify](https://ikiform.com/ja/guides/contact-form-static-site/netlify.md): Netlify Forms を使わずに、Netlify のサイトでお問い合わせフォームを運用します。ビルド時の検出なし、React からの JSON 送信、デプロイプレビュー用の別フォーム。
- [Cloudflare Pages](https://ikiform.com/ja/guides/contact-form-static-site/cloudflare-pages.md): Function を書かずに Cloudflare Pages にお問い合わせフォームを追加します。フォームバックエンドに送信し、_headers でヘッダーを設定し、自分の Turnstile キーを使います。
- [Amazon S3](https://ikiform.com/ja/guides/contact-form-static-site/aws-s3.md): S3 のウェブサイトエンドポイントは平文 HTTP の GET と HEAD しか受け付けません。お問い合わせフォームはフォームバックエンドに送信し、リダイレクトは CloudFront で正しく扱います。
- [ポートフォリオサイト](https://ikiform.com/ja/guides/contact-form-static-site/portfolio-website.md): 初回の打ち合わせで聞くことを尋ねるポートフォリオのお問い合わせフォーム。案件、予算、時期、リンクを、サーバーなしで受け取り、受信トレイから返信できます。

## 動作を確認する

本番のドメインで 1 回、ホストがプレビューを作るならプレビューでも 1 回、通して確認してください。Ikiform に無料プランはありません。テストは無料で、プランのないワークスペースでも `localhost` からのテスト送信を月 50 件まで受け付けます。本番運用するには、月 $6 で 500 件からの Pro を選ぶか、1 回払いで 10,000 件 $49 または 50,000 件 $149 のパックを購入します。クレジットに有効期限はありません。[料金](https://ikiform.com/ja/pricing)を参照してください。

1. フォームの **Allowed domains** にサイトのドメインを追加し、開発中は `localhost` も追加します。空のままにすると、どこからの送信でも受け付けます。
2. ローカルサーバーから送信し、**Submissions** にメッセージが表示されることを確認します。
3. デプロイしてから、JavaScript をオフにして本番のサイトから送信します。自分のサンクスページに移動するはずです。
4. JavaScript をオンに戻し、メッセージを空にして送信します。エラーはホストされたエラーページではなく、そのページ上に表示されるはずです。
5. 通知の受信者それぞれの受信トレイを確認します。各自にフィールド入りのメールが届き、フォームに `email` フィールドがあれば **Reply** の宛先は送信者になります。
6. ハニーポットを埋めて `_gotcha=bot` で送信します。成功の応答は返りますが、送信データはスパムとして保存され、メールも Webhook も送られません。
7. 何も届かない場合は、ダッシュボードのステータスフィルターを **Spam** に切り替えてください。スパムにも本物の送信と同じ成功の応答が返ります。

## トラブルシューティング

| 問題 | 対処 |
| --- | --- |
| 送信を押した直後に `405 Method Not Allowed` か `404` | フォームが自分のサイトに送信しています。ソースではなく、デプロイ後の HTML の `action` 属性を確認してください。ビルドの処理やフレームワークの `<Form>` コンポーネントが置き換えている可能性があります。 |
| 何も起きず、コンソールに `Content-Security-Policy` の記述がある | プレーンな送信のために `form-action` に、`fetch` のために `connect-src` に `https://ikiform.com` を追加してください。 |
| `403 origin_not_allowed` が返るか、すべての送信がスパムになる | ページのドメインがフォームの **Allowed domains** にありません。ドメインと、ローカルのテスト用に `localhost` を追加してください。登録済みドメインのサブドメインは許可されます。 |
| ローカルのテストが `402 plan_required` で拒否される | ページが `file://` の URL で開かれたため、送信に使えるオリジンがなく、本番扱いになりました。ローカルサーバーでフォルダーを配信し、`localhost` からテストしてください。 |
| 自分のページではなく Ikiform のサンクスページに移動する | `_redirect` は、ドメインが **Allowed domains** にあり、かつ `https:` の URL でない限り無視されます。ドメインを追加するか、代わりにフォーム設定で **Redirect after submit** を設定してください。 |
| `422 validation_failed` | 送信がフォームのフィールド定義に合っていません。`error.fields` に各フィールドと理由が示されます。入力欄の `name` をフィールド ID に合わせてください。 |
| 送信データはダッシュボードにあるのに、メールも Webhook も届かない | スパムとして保存されています。スパムは何も送りません。**Spam** フィルターで開いて理由を確認し、スパムではないとマークすると、メールと Webhook が送られます。 |
| `429 rate_limited` | 1 人の訪問者がフォームのレート制限を超えて送信しました。Standard では訪問者ごとに 1 分あたり 5 件までです。`Retry-After` の時間だけ待つか、1 つの共有 IP から送信するフォームには Relaxed か Custom を選んでください。 |

## よくある質問

### 静的なウェブサイトでも動くお問い合わせフォームを置けますか？

はい。ページは静的なままで、フォームの action だけを送信を受け付けるサービスに向けます。フォームバックエンドがメッセージごとに保存し、スパムを除外し、メールで通知するので、サーバーを運用する必要はありません。

### フォームのエンドポイントを公開 HTML に載せても安全ですか？

はい。フォーム ID は、お問い合わせページのメールアドレスと同じく、公開される前提のものです。フォームは許可ドメイン、ハニーポット、レート制限、人間確認で守ります。静的サイトに絶対に置いてはいけないのは、シークレットの API キーです。

### mailto: リンクを使わないのはなぜですか？

訪問者がメールアプリを設定している場合にしか動かず、何も保存されず、アドレスがページに載ってスクレイパーに拾われます。フォームなら訪問者はサイトにとどまり、すべてのメッセージの記録が残ります。

### GitHub Pages や Netlify で PHP の mail() を使えますか？

いいえ。静的ホストはページのために PHP も他のサーバーコードも実行しません。PHP のメールスクリプトにはサーバーが必要で、それこそ静的サイトが避けているものです。

### フォームでファイルのアップロードを受け付けられますか？

まだできません。ファイルを含む送信は 422 files_not_supported で拒否されます。代わりに、共有ドライブの URL などファイルへのリンクを尋ねてください。

### Ikiform は無料ですか？

いいえ、無料プランはありません。テストは無料で、支払いの前に localhost から月 50 件のテスト送信ができます。本番運用するには、月 $6 で 500 件からの Pro を選ぶか、1 回払いで 10,000 件 $49 または 50,000 件 $149 を購入できます。クレジットに有効期限はありません。

## その他のガイド

- [GitHub Pages](https://ikiform.com/ja/guides/contact-form-static-site/github-pages.md): GitHub Pages ではサーバーコードが動かず、フォームの POST は 405 Not Allowed になります。フォームバックエンドに送信し、プロジェクトサイトのリダイレクトを正しく設定しましょう。
- [Vercel](https://ikiform.com/ja/guides/contact-form-static-site/vercel.md): Vercel の静的ページはフォームの POST に 405 Method Not Allowed を返します。その理由と、代わりにバックエンドへフォームを送る方法を説明します。
- [Netlify](https://ikiform.com/ja/guides/contact-form-static-site/netlify.md): Netlify Forms を使わずに、Netlify のサイトでお問い合わせフォームを運用します。ビルド時の検出なし、React からの JSON 送信、デプロイプレビュー用の別フォーム。
- [Cloudflare Pages](https://ikiform.com/ja/guides/contact-form-static-site/cloudflare-pages.md): Function を書かずに Cloudflare Pages にお問い合わせフォームを追加します。フォームバックエンドに送信し、_headers でヘッダーを設定し、自分の Turnstile キーを使います。
- [Amazon S3](https://ikiform.com/ja/guides/contact-form-static-site/aws-s3.md): S3 のウェブサイトエンドポイントは平文 HTTP の GET と HEAD しか受け付けません。お問い合わせフォームはフォームバックエンドに送信し、リダイレクトは CloudFront で正しく扱います。
- [ポートフォリオサイト](https://ikiform.com/ja/guides/contact-form-static-site/portfolio-website.md): 初回の打ち合わせで聞くことを尋ねるポートフォリオのお問い合わせフォーム。案件、予算、時期、リンクを、サーバーなしで受け取り、受信トレイから返信できます。
- [フォームをメールに送る](https://ikiform.com/ja/guides/send-form-to-email.md): メールサーバーなしで、すべての送信データを受信トレイに届けます。mailto: と PHP の mail() がうまくいかない理由と、スパムを除外して返信もできる構成を解説します。
- [お問い合わせフォームのスパムを止める](https://ikiform.com/ja/guides/spam-protection.md): 本物の訪問者に負担をかけない層でお問い合わせフォームのスパムを止めます。ハニーポット、時間、内容のスコア、レート制限、必要なときだけの人間確認です。
- [ビルダーのフォームを卒業する](https://ikiform.com/ja/guides/website-builder-forms.md): Webflow、Netlify、Carrd、Framer のフォームが上限や機能不足に当たったら、デザインしたフォームはそのままに、送信先をフォームバックエンドに変えましょう。
