ガイド4 分で読めます
バックエンドなしで静的サイトにお問い合わせフォームを追加する
サーバーなしで静的サイトに動くお問い合わせフォームを追加します。POST の送り先、スパム、メールでの返信、各ホストとフレームワーク向けのコードまで。
静的ホストはファイルを配信するだけで、フォームの 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 は、自分で書いてホストするコードでも、ホストの機能でも、フォームバックエンドでも構いません。このガイドではこの先、サイトを静的なまま保てるフォームバックエンドを使います。
- action がないか、自分のサイトのパスを指す action だと、ブラウザーは静的ホストに送信します。
- ホストには POST に対して実行するコードがないので、405 か 404 を返し、メッセージは失われます。
- action を Ikiform のエンドポイントに向けると、送信はそれを受け付けるサーバーに届きます。
- Ikiform はメッセージのスパムチェックを行い、保存して、メールで通知します。
- Ikiform は 303 リダイレクトで応答し、ブラウザーは自分のサイトのサンクスページを読み込みます。
各メッセージに起きること
Ikiform はフォームの送信先のエンドポイントです。ページをホストしたりフォームをレンダリングしたりはせず、どちらも自分で持ちます。送信ごとにスパムチェックを行い、メッセージを保存し、あなたが読む場所に届けます。
- 訪問者がお問い合わせフォームに入力し、フォームが Ikiform のエンドポイントに送信されます。
- Ikiform はハニーポット、レート制限、スパムスコアを確認してから、メッセージを保存します。
- 通知の受信者それぞれに、reply-to を送信者のアドレスにしたメールが届きます。
- メッセージはダッシュボードに表示され、検索、スパムの復元、CSV のエクスポートができます。
- Webhook を追加すると、Ikiform は署名付きのコピーを Slack、Discord、自動化ツール、自分のコードに送ります。
静的サイトのフォームを受け取る 5 つの方法
| 方法 | 得意なこと | トレードオフ |
|---|---|---|
mailto: リンクまたは action="mailto:..." | 設定は一切不要。アクセスの少ない個人ページなら十分です。 | 訪問者のメールアプリを開きます(設定されていれば)。何も保存もフィルターもされず、アドレスが HTML に載るのでスクレイパーに拾われます。 |
| Netlify Forms などのホストのフォーム機能 | 属性 1 つで済み、別のアカウントは不要。たいていホスティングのプランに含まれています。 | そのホストでしか動きません。ホストを移るとフォームを作り直すことになり、JavaScript でレンダリングするフォームには回避策が必要です。 |
| 自分で書くサーバーレス関数 | バリデーション、保存、送るメールを完全に制御できます。 | バックエンドを抱えることになります。メール API のキー、スパム対策、レート制限、リトライ、ログ。関数の形式はホストごとに異なります。 |
| Google Forms、Tally、Typeform などのホスト型フォームの埋め込み | コード不要。長いフォームや複数ステップのフォームに向いています。 | iframe か別サイトへのリンクになり、相手の見た目とスクリプトがページに入ります。 |
| Ikiform、Formspree、Basin、Web3Forms などのフォームバックエンド | どのホストでも自分の HTML を使えます。サーバーなしで保存、スパム対策、メール通知ができます。 | アカウントと請求がもう 1 つ増え、フォームのデータはそのサービスに置かれます。 |
Netlify を使っていて移る予定がないなら、手間の少なさで Netlify Forms にかなうものはありません。バックエンドを自分で書きたいなら、関数は週末のプロジェクトにちょうどよい題材です。ホストやフレームワークを変えてもフォームを動かし続けたいなら、フォームバックエンドを使ってください。各バックエンドは比較ページで並べて比べられます。
フォーム
以下のサンプルはすべてブラウザーから送信します。静的デプロイには送信元になるサーバーがないからです。スタックを選んでください。選択はこのガイドのすべてのコードサンプルに引き継がれます。静的サイトジェネレーターには個別のセットアップページがあります: Jekyll、Hugo、Eleventy、Gatsby。
手書きのサイトなら、連携はこれで全部です。フォームは通常の方法で送信するので JavaScript がオフでも動き、送信に成功するとブラウザーは thanks.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>スクリプトは任意です。訪問者をページにとどめ、各フィールドのエラーを入力欄の下に表示し、失敗したときも入力したメッセージを残します。
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;
});output: "export" を指定すると、next build は out/ にプレーンなファイルを書き出します。リクエストを読む Server Actions や Route Handlers は静的エクスポートでは使えないので、フォームは代わりにブラウザーから送信します。
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "export",
};
export default nextConfig;"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 Actions は使わないでください。フォームを Action に送信するには、オンデマンドレンダリングとアダプターが必要になります。Ikiform に送信し、バンドルされる <script> で訪問者をページにとどめましょう。
---
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: のアドレスを設定してください。
SPA モードでは、どの静的ホストでも配信できるクライアントレンダリングのシェルがビルドされます。サーバー関数にはやはりサーバーが必要なので静的デプロイでは実行できず、ルートはブラウザーから送信して、成功したら /thanks に移動します。
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(),
],
});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>
);
}ssr: false ではサーバー用のビルドがないので、ルートにサーバーの action を持てません。代わりにブラウザーで clientAction が動き、<Form> の送信を Ikiform に転送して、成功したらリダイレクトします。
import type { Config } from "@react-router/dev/config";
export default {
ssr: false,
} satisfies Config;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>
);
}adapter-static と prerender = true では、すべてのページがファイルになります。SvelteKit のフォーム Action は使えません。Action のあるページは、サーバーが POST を処理する必要があるためプリレンダーできないからです。代わりにコンポーネントが fetch で送信します。
export const prerender = true;<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 generate はプリレンダーしたページを .output/public に書き出します。server/api のルートは静的ホストには存在しないので、コンポーネントが $fetch で直接 Ikiform を呼び、成功したら /thanks に移動します。
<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>静的ホスト上の Vite のシングルページアプリです。エンドポイントは VITE_ の変数に置くので、プレビューと本番のビルドで別のフォームを指せます。どちらにしてもバンドルに含まれますが、シークレットではないので問題ありません。
VITE_IKIFORM_ENDPOINT=https://ikiform.com/f/frm_xxxxxxxxxxxxxxxximport { 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 のシングルファイルコンポーネントで書き、同じ VITE_ の変数からエンドポイントを読みます。表示するのは最初のエラーだけで、ボタンの横に出します。
<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、SvelteKit、Nuxt の各ガイドに、シークレットキーについてはサーバーから送信するにあります。
IKIFORM_SECRET_KEY はサーバー専用の環境変数に置いてください。お問い合わせフォームのフィールド
名前、メールアドレス、メッセージがあれば、ほとんどのお問い合わせフォームは足ります。メッセージの種類によって対応する人が違うなら、件名の選択欄を追加する価値があります。下のフォームを試してみてください。ブラウザーで Ikiform のバリデーターを実行し、保存される内容か、返ってくる 422 を表示します。
プレビュー
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 として渡されます。
メッセージをメールの代わりに、またはメールに加えてチームのチャットで受け取りたいですか? Webhook を追加して、Slack、Discord、Telegram の手順に従ってください。
送信後に訪問者に表示されるもの
プレーンな HTML の送信はリダイレクトで終わります。Ikiform はブラウザーからの送信が成功すると 303 See Other で応答し、ブラウザーは移動先を GET で読み込みます。これなら静的ホストも喜んで配信します。
- ブラウザーがフォームのフィールドを Ikiform のエンドポイントに送信します。
- Ikiform はメッセージを保存し、Location ヘッダー付きの 303 See Other で応答します。
- ブラウザーはリダイレクトに従ってサンクスページを GET し、静的ホストはそれを他のファイルと同じように配信します。
Ikiform は次の順番で移動先を選びます:
- フォーム設定の Redirect after submit。設定できるのはチームだけなので、
http:でもhttps:でも任意の URL を使えます。サイトが 1 つならこれがいちばん簡単です。 - フォーム内の
_redirectフィールド。フォームの Allowed domains にあるhttps:の URL の場合に限ります。1 つのフォームを複数のサイトや言語で使うときに使います。 - どちらもなければ、Ikiform がホストするサンクスページ。許可された
https:のページからの送信なら、サイトへ戻るリンクが付きます。
サンクスページは実在するファイルにし、検索結果には出ないようにしてください。いつ返信するかを書いておけば、同じ人から 2 通目のメールが来るのを防げます。
<!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 が合っています。各層の説明はスパムのドキュメントにあり、人間確認を使うには JavaScript 版で SDK の 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 です。どちらかが欠けていると、リクエストはページを出る前にブラウザーにブロックされ、手がかりはコンソールのメッセージだけになります。
Content-Security-Policy: default-src 'self'; form-action 'self' https://ikiform.com; connect-src 'self' https://ikiform.comform-action には 'self' を残してください。ブラウザーによっては、サンクスページへのリダイレクトもこれと照合するためです。ヘッダーを置く場所はホストによって異なります。Netlify と Cloudflare Pages は _headers、Vercel は vercel.json、GitHub Pages は <meta> タグ、S3 なら前段の CloudFront のレスポンスヘッダーポリシーです。それぞれの方法は下のホスト別ページにあります。
デプロイ前にテストする
ファイルをダブルクリックするのではなく、ローカルサーバー経由でページを開いてください。file:// で開いたページは Origin: null を送り、どの許可ドメインにも一致せず、ローカルのテストとしても扱われません。次のどれでも現在のフォルダーを配信できます:
npx serve .
python3 -m http.server 8000テスト中は localhost を Allowed domains に追加してください。localhost からの送信はテストとして扱われ、プランのないワークスペースでも月 50 件まで使えるので、支払いの前にフォーム全体を作り上げられます。
状況に合わせて選ぶ
上のフォームはどの静的ホストでも動きます。ホストごとに失敗のしかた、プレビュー用ドメイン、ヘッダーファイルが異なります。それらは次のページで説明しています。
- GitHub PagesGitHub Pages ではサーバーコードが動かず、フォームの POST は 405 Not Allowed になります。フォームバックエンドに送信し、プロジェクトサイトのリダイレクトを正しく設定しましょう。
- VercelVercel の静的ページはフォームの POST に 405 Method Not Allowed を返します。その理由と、代わりにバックエンドへフォームを送る方法を説明します。
- NetlifyNetlify Forms を使わずに、Netlify のサイトでお問い合わせフォームを運用します。ビルド時の検出なし、React からの JSON 送信、デプロイプレビュー用の別フォーム。
- Cloudflare PagesFunction を書かずに Cloudflare Pages にお問い合わせフォームを追加します。フォームバックエンドに送信し、_headers でヘッダーを設定し、自分の Turnstile キーを使います。
- Amazon S3S3 のウェブサイトエンドポイントは平文 HTTP の GET と HEAD しか受け付けません。お問い合わせフォームはフォームバックエンドに送信し、リダイレクトは CloudFront で正しく扱います。
- ポートフォリオサイト初回の打ち合わせで聞くことを尋ねるポートフォリオのお問い合わせフォーム。案件、予算、時期、リンクを、サーバーなしで受け取り、受信トレイから返信できます。
動作を確認する
本番のドメインで 1 回、ホストがプレビューを作るならプレビューでも 1 回、通して確認してください。Ikiform に無料プランはありません。テストは無料で、プランのないワークスペースでも localhost からのテスト送信を月 50 件まで受け付けます。本番運用するには、月 $6 で 500 件からの Pro を選ぶか、1 回払いで 10,000 件 $49 または 50,000 件 $149 のパックを購入します。クレジットに有効期限はありません。料金を参照してください。
- フォームの Allowed domains にサイトのドメインを追加し、開発中は
localhostも追加します。空のままにすると、どこからの送信でも受け付けます。 - ローカルサーバーから送信し、Submissions にメッセージが表示されることを確認します。
- デプロイしてから、JavaScript をオフにして本番のサイトから送信します。自分のサンクスページに移動するはずです。
- JavaScript をオンに戻し、メッセージを空にして送信します。エラーはホストされたエラーページではなく、そのページ上に表示されるはずです。
- 通知の受信者それぞれの受信トレイを確認します。各自にフィールド入りのメールが届き、フォームに
emailフィールドがあれば Reply の宛先は送信者になります。 - ハニーポットを埋めて
_gotcha=botで送信します。成功の応答は返りますが、送信データはスパムとして保存され、メールも Webhook も送られません。 - 何も届かない場合は、ダッシュボードのステータスフィルターを 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 だけを送信を受け付けるサービスに向けます。フォームバックエンドがメッセージごとに保存し、スパムを除外し、メールで通知するので、サーバーを運用する必要はありません。
はい。フォーム ID は、お問い合わせページのメールアドレスと同じく、公開される前提のものです。フォームは許可ドメイン、ハニーポット、レート制限、人間確認で守ります。静的サイトに絶対に置いてはいけないのは、シークレットの API キーです。
訪問者がメールアプリを設定している場合にしか動かず、何も保存されず、アドレスがページに載ってスクレイパーに拾われます。フォームなら訪問者はサイトにとどまり、すべてのメッセージの記録が残ります。
いいえ。静的ホストはページのために PHP も他のサーバーコードも実行しません。PHP のメールスクリプトにはサーバーが必要で、それこそ静的サイトが避けているものです。
まだできません。ファイルを含む送信は 422 files_not_supported で拒否されます。代わりに、共有ドライブの URL などファイルへのリンクを尋ねてください。
いいえ、無料プランはありません。テストは無料で、支払いの前に localhost から月 50 件のテスト送信ができます。本番運用するには、月 $6 で 500 件からの Pro を選ぶか、1 回払いで 10,000 件 $49 または 50,000 件 $149 を購入できます。クレジットに有効期限はありません。
手順で詰まったときや、あなたの状況に合うガイドがほしいときはメールでどうぞ: hello@ikiform.com