ビルダーのフォームを卒業する2 分で読めます
Netlify Forms の上限:100 件を超えると何が起きるか、そして抜け出し方
Netlify の旧プランはサイトごとにフォーム送信を計測して自動でアップグレードし、クレジットプランはサイトを一時停止することがあるので、フォームをバックエンドに移しましょう。
Netlify の旧プランでは、各サイトに月 100 件のフォーム送信が含まれ、それを超えるとサイトが自動的に有料の Forms レベルにアップグレードされます。2025 年 9 月以降に作成したアカウント向けのクレジット制プランではフォームは無料ですが、無料のチームがクレジットを使い切ると、フォームも含めてすべてのサイトが一時停止されます。フォームを Ikiform に移すには、素の HTML でもどのフレームワークでも、Netlify の属性を削除して action を設定するだけです。
Netlify ならではの違い
Netlify はサイトビルダーではなく、フォーム機能を内蔵したホスティングです。これが上限にも抜け出し方にも影響します。どの上限が適用されるかは、アカウントの作成時期で決まります。
| アカウント | Netlify のドキュメントの記載 | 送信の多いフォームにとっての意味 |
|---|---|---|
| 旧プラン(2025 年 9 月 4 日より前に作成) | Forms Level 0 には、サイトごと・月ごとに 100 件の送信と 10 MB のアップロードが含まれます。超えるとサイトは 1,000 件の Forms Level 1 にアップグレードされ、請求サイクルの終わりに課金されます。それを超えると、追加の利用パッケージが 500 件ずつ加わります | スパムの波で、誰も決めていないのにサイトが有料レベルに上がることがあります。無料の旧プランでは、上限に達するとアカウントのすべてのサイトが一時停止されます |
| クレジット制プラン(それ以降に作成) | フォームの送信は無料かつ無制限で、レベルはありません | フォーム自体は無料です。ただし、無料のチームが月のクレジットを使い切ると Netlify はすべてのプロジェクトを一時停止し、一時停止したプロジェクトはフォームの送信を受け付けません |
Netlify 自身のドキュメントに、旧プランのアップグレードは自動であること、利用量が下のレベルを上回っている間は戻せないことが明記されています。何かを決める前に、Netlify のダッシュボードでチームのプランの種類とサイトのフォーム利用量を確認してください。
移行する 2 つ目の理由は、数字とは関係ありません。Netlify Forms は、デプロイされた HTML を読み取って動きます。
- フォームはデプロイ時に検出されます。React、Vue、Svelte が描画するフォームはその HTML に存在しないので、ビルドが見つけられるように非表示の静的なコピーを置き、
form-nameフィールド付きでそこに送信します。 - Next.js では、OpenNext アダプターがそのコピーを
public/の静的ファイルとして必要とし、送信は Next.js のページではなくその静的ファイルに向ける必要があります。それがないまま React のコードにdata-netlifyがあると、ビルドが失敗します。 - 送信は urlencoded か multipart でなければなりません。JSON は受け付けられません。
- フォームが動くのは、サイトが Netlify にデプロイされている間だけです。ホストを移すと、フォームも移すことになります。
- 旧プランでは、サイトは月の初めに 100 件の送信を含む Forms Level 0 から始まります。
- 101 件目の送信が届きます。Netlify の利用量のドキュメントはサイトの利用量を対象にしているので、スパムの急増が上限を超えさせる原因になることもあります。
- Netlify はサイトを 1,000 件の Forms Level 1 にアップグレードし、サイクルの終わりに請求します。
- Level 1 を超えると、追加の利用パッケージが 500 件ずつ加わります。
- フォームを Ikiform に送信するようにすると、ページは Netlify に置いたまま、送信は Ikiform のエンドポイントに向かいます。
- Netlify Forms は送信を受け取らないので、数えるものもアップグレードするものもありません。
スタックごとにフォームを移す
コードを変える前に、Netlify のフォーム名ごとに Ikiform のフォームを 1 つ作り、その Honeypot field を bot-field に設定します。そうすれば既存の非表示の入力欄が、名前を変えずにそのままハニーポットとして働きます。スタックを選んでください。
Netlify 上の素の HTML なら、変更はいちばん少なくて済みます。Netlify の属性と form-name の入力欄を削除し、action をエンドポイントに向け、サンクスページのパスを完全な URL として _redirect に移します。bot-field の入力欄はハニーポットとして残します。
<form name="contact" method="POST" action="/thanks" data-netlify="true" netlify-honeypot="bot-field">
<input type="hidden" name="form-name" value="contact" />
<p hidden><input name="bot-field" /></p>
<label>Email <input type="email" name="email" required /></label>
<label>Topic
<select name="topic">
<option value="question">A question</option>
<option value="bug">Something is broken</option>
<option value="work">Working together</option>
</select>
</label>
<label>Message <textarea name="message" required></textarea></label>
<button type="submit">Send</button>
</form>Netlify 上の Next.js のフォームは、public/__forms.html のような public/ の静的なコピーにコンポーネントから送信することでしか Netlify Forms と連携できませんでした。そのファイルを削除し、NETLIFY_NEXT_VERIFY_FORMS の設定があれば外して、コンポーネントから Ikiform に送信します。action があるので、フォームは JavaScript なしでも送信できます。
"use client";
import { type FormEvent, useState } from "react";
const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";
export function ContactForm() {
const [state, setState] = useState<"idle" | "sending" | "sent">("idle");
const [error, setError] = useState("");
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setState("sending");
setError("");
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) return setState("sent");
setError(result.error.fields?.message?.[0] ?? result.error.fields?.email?.[0] ?? result.error.message);
} catch {
setError("Couldn't send. Check your connection and try again.");
}
setState("idle");
}
if (state === "sent") return <p role="status">Thanks. We read every message and reply within a day.</p>;
return (
<form action={ENDPOINT} method="post" onSubmit={onSubmit}>
<input name="bot-field" type="text" tabIndex={-1} autoComplete="off" hidden />
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" required />
<label htmlFor="topic">Topic</label>
<select id="topic" name="topic" defaultValue="question">
<option value="question">A question</option>
<option value="bug">Something is broken</option>
<option value="work">Working together</option>
</select>
<label htmlFor="message">Message</label>
<textarea id="message" name="message" required />
{error ? <p role="alert">{error}</p> : null}
<button disabled={state === "sending"}>{state === "sending" ? "Sending" : "Send"}</button>
</form>
);
}静的な Astro のページなら、JavaScript はまったく要りません。フォームから data-netlify を削除し、エンドポイントを action に設定して、astro.config.mjs の site からリダイレクト先を組み立てます。ページはプリレンダーのままでかまいません。
---
const endpoint = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";
const thanksUrl = new URL("/thanks", Astro.site).href;
---
<form method="post" action={endpoint}>
<input name="bot-field" type="text" tabindex="-1" autocomplete="off" hidden />
<label for="email">Email</label>
<input id="email" name="email" type="email" required />
<label for="topic">Topic</label>
<select id="topic" name="topic">
<option value="question">A question</option>
<option value="bug">Something is broken</option>
<option value="work">Working together</option>
</select>
<label for="message">Message</label>
<textarea id="message" name="message" required></textarea>
<input type="hidden" name="_redirect" value={thanksUrl} />
<button>Send</button>
</form>Netlify 上の TanStack Start アプリはフォームを React で描画するので、Netlify のデプロイ時の検出では見つけられませんでした。代わりにサーバー関数経由で送信し、非表示の静的なコピーは削除します。Netlify でサイトの環境変数に IKIFORM_SECRET_KEY を設定します。関数はブラウザーの Origin ヘッダーを引き継ぐので、ワークスペースにプランがなくても localhost からの送信は無料のテストとして数えられます。
import { createFileRoute } from "@tanstack/react-router";
import { createServerFn, useServerFn } from "@tanstack/react-start";
import { getRequestHeader } from "@tanstack/react-start/server";
import { type FormEvent, useState } from "react";
const sendMessage = createServerFn({ method: "POST" })
.validator((data: unknown) => {
if (!(data instanceof FormData)) throw new Error("Expected form data");
return {
email: String(data.get("email") ?? "").trim(),
topic: String(data.get("topic") ?? "question"),
message: String(data.get("message") ?? "").trim(),
"bot-field": String(data.get("bot-field") ?? ""),
};
})
.handler(async ({ data }) => {
const origin = getRequestHeader("origin");
const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.IKIFORM_SECRET_KEY}`,
...(origin ? { Origin: origin } : {}),
},
body: JSON.stringify(data),
});
const result = await response.json();
return result.ok ? { sent: true, error: "" } : { sent: false, error: String(result.error.message) };
});
export const Route = createFileRoute("/contact")({ component: Contact });
function Contact() {
const send = useServerFn(sendMessage);
const [state, setState] = useState({ sent: false, error: "" });
const [pending, setPending] = useState(false);
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setPending(true);
try {
setState(await send({ data: new FormData(event.currentTarget) }));
} catch {
setState({ sent: false, error: "Couldn't send. Try again." });
} finally {
setPending(false);
}
}
if (state.sent) return <p role="status">Thanks. We read every message and reply within a day.</p>;
return (
<form method="post" onSubmit={onSubmit}>
<input name="bot-field" type="text" tabIndex={-1} autoComplete="off" hidden />
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" required />
<label htmlFor="topic">Topic</label>
<select id="topic" name="topic" defaultValue="question">
<option value="question">A question</option>
<option value="bug">Something is broken</option>
<option value="work">Working together</option>
</select>
<label htmlFor="message">Message</label>
<textarea id="message" name="message" required />
{state.error ? <p role="alert">{state.error}</p> : null}
<button disabled={pending}>{pending ? "Sending" : "Send"}</button>
</form>
);
}フレームワークモードの React Router では、ルートの action が送信を転送します。FormData はそのまま渡されるので bot-field は Ikiform のハニーポットのチェックに届き、ブラウザーの Origin ヘッダーも一緒に送られるので localhost からの送信はテストとして数えられます。Netlify 用に置いていた静的なコピーは削除します。
import { data, Form, useNavigation } from "react-router";
import type { Route } from "./+types/contact";
export async function action({ request }: Route.ActionArgs) {
const form = await request.formData();
const origin = request.headers.get("origin");
const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
method: "POST",
headers: {
Accept: "application/json",
Authorization: `Bearer ${process.env.IKIFORM_SECRET_KEY}`,
...(origin ? { Origin: origin } : {}),
},
body: form,
});
const result = await response.json();
if (result.ok) return { sent: true, error: "" };
return data({ sent: false, error: String(result.error.message) }, { status: response.status });
}
export default function Contact({ actionData }: Route.ComponentProps) {
const sending = useNavigation().state === "submitting";
if (actionData?.sent) return <p role="status">Thanks. We read every message and reply within a day.</p>;
return (
<Form method="post">
<input name="bot-field" type="text" tabIndex={-1} autoComplete="off" hidden />
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" required />
<label htmlFor="topic">Topic</label>
<select id="topic" name="topic" defaultValue="question">
<option value="question">A question</option>
<option value="bug">Something is broken</option>
<option value="work">Working together</option>
</select>
<label htmlFor="message">Message</label>
<textarea id="message" name="message" required />
{actionData?.error ? <p role="alert">{actionData.error}</p> : null}
<button disabled={sending}>{sending ? "Sending" : "Send"}</button>
</Form>
);
}Netlify 上の SvelteKit のページは、自前のフォームアクションなしでブラウザーから直接送信できます。action 属性があるので、ハイドレーション前でもフォームは動きます。Netlify 用に追加したフォームのプリレンダー済みのコピーは削除します。
<script lang="ts">
const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";
let sent = $state(false);
let sending = $state(false);
let error = $state("");
async function send(event: SubmitEvent) {
event.preventDefault();
sending = true;
error = "";
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) sent = true;
else error = result.error.message;
} catch {
error = "Couldn't send. Check your connection and try again.";
}
sending = false;
}
</script>
{#if sent}
<p role="status">Thanks. We read every message and reply within a day.</p>
{:else}
<form method="post" action={ENDPOINT} onsubmit={send}>
<input name="bot-field" type="text" tabindex="-1" autocomplete="off" hidden />
<label for="email">Email</label>
<input id="email" name="email" type="email" required />
<label for="topic">Topic</label>
<select id="topic" name="topic">
<option value="question">A question</option>
<option value="bug">Something is broken</option>
<option value="work">Working together</option>
</select>
<label for="message">Message</label>
<textarea id="message" name="message" required></textarea>
{#if error}<p role="alert">{error}</p>{/if}
<button disabled={sending}>{sending ? "Sending" : "Send"}</button>
</form>
{/if}Nuxt ではコンポーネントがブラウザーから $fetch で送信するので、デプロイするサーバールートはありません。$fetch は 2xx 以外の応答で例外を投げ、Ikiform のエラーエンベロープはエラーの data に入っています。public/ の非表示の静的フォームと form-name フィールドを削除します。
<script setup lang="ts">
const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";
const sent = ref(false);
const sending = ref(false);
const error = ref("");
async function send(event: Event) {
sending.value = true;
error.value = "";
try {
await $fetch(ENDPOINT, {
method: "POST",
headers: { Accept: "application/json" },
body: new FormData(event.target as HTMLFormElement),
});
sent.value = true;
} catch (failure) {
const data = (failure as { data?: { error?: { message?: string } } }).data;
error.value = data?.error?.message ?? "Couldn't send. Check your connection and try again.";
} finally {
sending.value = false;
}
}
</script>
<template>
<p v-if="sent" role="status">Thanks. We read every message and reply within a day.</p>
<form v-else :action="ENDPOINT" method="post" @submit.prevent="send">
<input name="bot-field" type="text" tabindex="-1" autocomplete="off" hidden />
<label for="email">Email</label>
<input id="email" name="email" type="email" required />
<label for="topic">Topic</label>
<select id="topic" name="topic">
<option value="question">A question</option>
<option value="bug">Something is broken</option>
<option value="work">Working together</option>
</select>
<label for="message">Message</label>
<textarea id="message" name="message" required></textarea>
<p v-if="error" role="alert">{{ error }}</p>
<button :disabled="sending">{{ sending ? "Sending" : "Send" }}</button>
</form>
</template>Netlify 上の React のシングルページアプリは、form-name フィールド付きで urlencoded のデータを / に送信し、検出用に index.html に静的なコピーが必要でした。今度は FormData をエンドポイントに送信します。変わるのはリクエストだけです。
const body = new URLSearchParams(new FormData(event.currentTarget) as unknown as Record<string, string>);
body.set("form-name", "contact");
await fetch("/", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: body.toString(),
});Netlify に何も送信しなくなったら、index.html から非表示の <form netlify> を削除します。
Vue の単一ファイルコンポーネントでの、同じブラウザー側の送信です。Netlify に検出させるための非表示のフォームを index.html から削除します。
<script setup lang="ts">
import { ref } from "vue";
const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";
const state = ref<"idle" | "sending" | "sent">("idle");
const error = ref("");
async function send(event: Event) {
state.value = "sending";
error.value = "";
try {
const response = await fetch(ENDPOINT, {
method: "POST",
headers: { Accept: "application/json" },
body: new FormData(event.target as HTMLFormElement),
});
const result = await response.json();
if (result.ok) {
state.value = "sent";
return;
}
error.value = result.error.message;
} catch {
error.value = "Couldn't send. Check your connection and try again.";
}
state.value = "idle";
}
</script>
<template>
<p v-if="state === 'sent'" role="status">Thanks. We read every message and reply within a day.</p>
<form v-else :action="ENDPOINT" method="post" @submit.prevent="send">
<input name="bot-field" type="text" tabindex="-1" autocomplete="off" hidden />
<label for="email">Email</label>
<input id="email" name="email" type="email" required />
<label for="topic">Topic</label>
<select id="topic" name="topic">
<option value="question">A question</option>
<option value="bug">Something is broken</option>
<option value="work">Working together</option>
</select>
<label for="message">Message</label>
<textarea id="message" name="message" required></textarea>
<p v-if="error" role="alert">{{ error }}</p>
<button :disabled="state === 'sending'">{{ state === "sending" ? "Sending" : "Send" }}</button>
</form>
</template>IKIFORM_SECRET_KEY はサーバー専用の環境変数に置いてください。どの版も Netlify と同じフィールド名を使うので、通知メールや CSV の列は見慣れたままです。Netlify Forms との比較に、data-netlify-recaptcha やファイルの入力欄も含めた属性の対応表があります。
デプロイプレビューと許可ドメイン
Netlify はプルリクエストごとに deploy-preview-42--your-site.netlify.app のような専用のアドレスを付けます。これは your-site.netlify.app のサブドメインではないので、Allowed domains にサイトのアドレスを入れてもプレビューはカバーされず、プレビューからの送信は origin_mismatch でスパムになります。
- 本番用に
your-site.netlify.appと独自ドメインを追加します。 - テストしている間だけ特定のプレビューのホストを追加し、終わったら削除します。
netlify.app単体は追加しないでください。Netlify 上のすべてのサイトがあなたのフォームに送信できるようになってしまいます。- または、環境変数を使ってプレビューを許可ドメインのない 2 つ目の Ikiform のフォームに向け、本番は厳格なままにします。
送信を失わずに切り替える
- サイトの Forms セクションから、フォームごとの過去の送信データを CSV でダウンロードします。Ikiform にはインポート機能がありません。
- 新しいフォームをプレビューにデプロイしてテストを送ります。Ikiform のダッシュボードでフィールド名を確認します。
- 本番にマージし、本番のドメインからもう一度テストを送ります。
- Netlify の送信 Webhook は Ikiform の Webhook に置き換えます。Netlify は JWS トークンで署名し、Ikiform は Standard Webhooks のヘッダーを使うので、検証処理も変わります。
- 数日間 Netlify に送信が届かなくなったら、サイトの Forms 設定でフォームの検出をオフにします。
422 files_not_supported が返ります。ファイルが必要なフォームがあれば、そのフォームだけ Netlify Forms に残し、残りを移してください。Netlify で動作を確認する
- フォームの Allowed domains に自分のサイトのドメインを追加し、開発中は
localhostも追加します。空のままにすると、どこからの送信も受け付けます。 - プレビューをデプロイし、そのホストを追加して、そこからフォームを送信します。
- ダッシュボードで Submissions を開きます。送ったすべてのフィールドを含む新しい送信データがあります。
- フィールドが
email、topic、messageで、form-nameもbot-fieldもないことを確認します。 - Netlify でサイトの Forms タブを開き、テストがそこにないことを確認します。
bot-fieldを埋めて 1 回送信します。スパムとして保存され、何も送られません。- 通知の各受信者の受信トレイを確認します。それぞれにフィールド入りのメールが届き、フォームに
emailフィールドがあれば Reply で送信者に返信できます。
ハマりどころ
| 問題 | 対処法 |
|---|---|
Ikiform に form-name フィールドが表示される | 非表示の form-name の入力欄がまだフォームに残っているか、コードがまだ設定しています。削除してください。Ikiform は URL でフォームを識別します。 |
| フォームに関するエラーで Next.js のビルドが失敗する | React のコードに netlify か data-netlify の属性がまだ残っています。public/__forms.html と一緒に削除してください。 |
| プレビューからの送信がスパムになる | プレビューのホストが Allowed domains にありません。テスト中だけ追加するか、プレビュー用に 2 つ目のフォームを使ってください。 |
| 本物も含めて、すべての送信がスパムになる | 入力欄は bot-field なのに Honeypot field が _gotcha のまま、またはその逆です。両者を合わせてください。 |
422 files_not_supported | フォームの <input type="file"> でファイルが選択されています。ファイルアップロードにはまだ対応していません。入力欄を削除するか、代わりにリンクを尋ねてください。 |
429 rate_limited | 1 人の訪問者がフォームのレート制限を超えて送信しました。Standard では訪問者ごとに 1 分あたり 5 件までです。Retry-After まで待つか、共有の IP 1 つから送信するフォームには Relaxed か Custom を選んでください。 |
よくある質問
旧プランでは、Netlify はサイトを月 1,000 件の送信を含む Forms Level 1 にアップグレードし、請求サイクルの終わりに課金します。無料の旧プランでは、上限に達するとアップグレードするまでアカウントのすべてのサイトが一時停止されます。
Netlify のドキュメントでは、クレジット制プランのフォーム送信は無料かつ無制限とされています。落とし穴はサイトそのものです。無料のチームが月のクレジットを使い切ると、プロジェクトは一時停止され、フォームの送信を受け付けません。
はい。Ikiform はページがどこでホストされていてもかまいません。フォームから Netlify の属性を削除して action を Ikiform のエンドポイントにすれば、Netlify Forms はそのフォームを認識しなくなります。
いいえ。あのコピーは、Netlify のデプロイ処理がフォームを見つけるためだけにあります。Ikiform は URL で送信を受け取るので、コピーを削除してコンポーネントから送信してください。
いいえ、無料プランはありません。テストは無料で、支払う前に localhost から月 50 件のテスト送信ができます。本番運用するには、月 $6 で 500 件からの Pro か、1 回払いで 10,000 件 $49 または 50,000 件 $149 のパックを選べます。クレジットに有効期限はありません。
手順で詰まったときや、あなたの状況に合うガイドがほしいときはメールでどうぞ: hello@ikiform.com