ガイド4 分で読めます
HTML フォームの送信の仕組み
フォームが送信されたときにブラウザーが行うこと。どのフィールドを送り、どうエンコードし、サーバーは何を返すべきか、そして Ikiform がどう応答するかを解説します。
送信ボタンが押されると、ブラウザーはフォームの制約をチェックし、name があり無効になっていないコントロールをすべて集め、フォームの enctype でエンコードして、フォームの method で action の URL に送ります。サーバーは通常のページ読み込みと同じように応答します。行儀のよいサーバーは、成功した POST に 303 リダイレクトで応答するので、再読み込みしてもフォームが二重に送信されません。action を Ikiform などのフォームバックエンドに向ければ、自前のサーバーなしで、そのリダイレクト、フィールドの検証、スパムフィルタリングが手に入ります。
HTML フォームは JavaScript より古く、今でもウェブページからデータを送るいちばん確実な方法です。スクリプトがオフでも、遅いスマートフォンでも、マークアップを貼り付けられるどのビルダーでも動きます。フォームのバグの多くは、ブラウザーが自分で何をしているかを知らないことから生まれます。このガイドでは送信を 1 ステップずつたどり、そのあとでフォームバックエンドがどう組み込まれるかを示します。
送信ボタンを押すと何が起きるか
送信は、ボディが付いた通常のナビゲーションです。ブラウザーは、ページから何かが出ていく前に 4 つのことを行い、そのあとリクエストをネットワークに渡して、返ってきたものを読み込みます。
- 送信ボタン、テキストフィールドでの Enter キー、または form.requestSubmit() で送信が始まり、submit イベントが発生します。
- フォームに novalidate がない限り、ブラウザーはすべてのコントロールの制約をチェックします。最初の無効なコントロールにメッセージが表示され、何も送信されません。
- ブラウザーは、name があり無効になっていないすべてのコントロールからフォームデータセットを作ります。
- そのデータセットをフォームの enctype でエンコードします。指定しなければ urlencoded です。
- フォームの method で action の URL にナビゲートし、ボディを送ります。
- サーバーが応答します。303 はブラウザーに別の URL を GET で読み込ませ、それが訪問者が最終的に見るページになります。
リクエストを決める属性
<form> の 5 つの属性が、データをどこへどう送るかを決めます。どれも、対応する form* 属性で 1 つのボタンだけ上書きできます。こうして、1 つのフォームに動作の違う Send と Save draft のボタンを置けます。
| 属性 | デフォルト | 役割 | ボタンでの上書き |
|---|---|---|---|
action | ページ自身の URL | データを受け取る URL。相対値はページを基準に解決されます。action 属性を参照してください | formaction |
method | get | get はフィールドを URL のクエリ文字列に入れます。post はボディとして送ります。dialog は何も送らずに <dialog> を閉じます | formmethod |
enctype | application/x-www-form-urlencoded | post のボディのエンコード方法。ファイルには multipart/form-data が必要です。text/plain はデバッグ用です | formenctype |
novalidate | オフ | 送信時にブラウザーの制約チェックを飛ばします | formnovalidate |
target | _self | レスポンスを読み込む場所。_blank は新しいタブを開き、名前付きの iframe ならページはそのままです | formtarget |
2 つのボタンで 2 つの動作。2 つ目は検証を飛ばします。クリックされたボタン自身の name と value がデータに加わるので、サーバーはどちらが押されたかがわかります。
<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="post">
<input name="email" type="email" required />
<textarea name="details" required></textarea>
<button name="intent" value="report">Send report</button>
<button name="intent" value="draft" formnovalidate>Save as draft</button>
</form>どのコントロールが送られるか
ブラウザーが送るのは、HTML 仕様でフォームデータセットと呼ばれるものだけです。送信ログで驚くことの多くは、次のルールから来ています。
- コントロールには
nameが必要です。idだけでは何も送られません。 disabledのコントロールは飛ばされます。値を表示してロックしたまま送りたいならreadonlyを使ってください。- チェックされていないチェックボックスやラジオボタンは、
falseではなく何も送りません。valueのないチェックボックスは、チェックされるとonを送ります。 - 含まれるのは、フォームを送信したボタンだけで、その
nameとvalueが入ります。 <select multiple>や、name を共有するチェックボックスのグループは、選択された値ごとに 1 組を送ります。<form>要素の外にある入力欄も、フォームのidを指定したform属性でフォームに加えられます。
ボディのエンコード方法
デフォルトの enctype では、ボディは & でつないだ name=value の組の 1 行で、スペースは + に、安全な文字以外はすべてパーセントエンコードされます。これは、下にある報告フォームをブラウザーが送ったときの実際のリクエストです。
POST /f/frm_xxxxxxxxxxxxxxxx HTTP/1.1
Host: ikiform.com
Content-Type: application/x-www-form-urlencoded
Origin: https://example.com
Sec-Fetch-Mode: navigate
email=ada%40example.com&page=https%3A%2F%2Fexample.com%2Fpricing&severity=broken&details=The+annual+toggle+does+nothing&_redirect=https%3A%2F%2Fexample.com%2Freport-sent&_gotcha=multipart/form-data は、ブラウザーが選んだ境界文字列で各フィールドを別々のパートに包みます。サイズは大きくなりますが、ファイルを運べます。Ikiform はどちらも読めます。text/plain には値の終わりを確実に判別する方法がないので、Ikiform は 415 unsupported_media_type で拒否します。
POST /f/frm_xxxxxxxxxxxxxxxx HTTP/1.1
Host: ikiform.com
Content-Type: multipart/form-data; boundary=----formdata7f3a
------formdata7f3a
Content-Disposition: form-data; name="email"
ada@example.com
------formdata7f3a
Content-Disposition: form-data; name="severity"
broken
------formdata7f3a--415 unsupported_charset で拒否します。ファイルアップロードにはまだ対応していません。ファイル付きの送信には 422 files_not_supported が返ります。フォームを送信する 4 つの方法
通常の送信は選択肢の 1 つにすぎません。以下のどの方法も、場面によっては妥当な選択です。違うのは、誰が応答を処理するかと、スクリプトがオフのときに何が起きるかです。
| 方法 | 得意なこと | トレードオフ |
|---|---|---|
リダイレクト付きの素の method="post" | JavaScript なしで、どのビルダーでも動きます。読み込み中の状態、戻る、再読み込みはブラウザーが処理します | ページ全体の読み込み。フィールドエラーには、サーバーで描画したページかバックエンドのエラーページが必要です |
FormData を使った fetch | 再読み込みなし。成功時や各フィールドエラーの見せ方を自分で決められます | 読み込み中の状態、ネットワークエラー、フォーカスを自分で扱います。気をつけないと、スクリプトがオフのときに壊れます。ページを再読み込みせずに送信するを参照してください |
| Next.js のサーバーアクションや SvelteKit のフォームアクションなど、フレームワークのフォームアクション | 送信を最初に受け取るのは自分のサーバーで、同じフォームがハイドレーションの前後どちらでも動きます | サーバーのランタイムが必要で、バックエンドへのリクエストは自分のサーバーから出ていくので、レート制限やオリジンのチェックの扱いが変わります |
method="get" | 検索やフィルターのフォーム。結果に共有できる URL が付きます | フィールドが URL、履歴、サーバーログに残ります。個人データや状態を変更するものには絶対に使わないでください |
まず素の送信から始め、その上に fetch を足してください。そうすれば、スクリプトの読み込みに失敗してもフォームは動きます。これは多くのアナリティクスが示すよりも頻繁に起きます。
サーバーは何を返すべきか
ブラウザーは、POST への応答を新しいページとして扱います。そのページが応答そのもの(ステータス 200)だと、ブラウザーはそれが POST から来たことを覚えています。再読み込みするとフォームを再送信するか尋ねられ、戻ると進むでも同じことをするブラウザーがあります。こうして二重に支払いが起き、サポートに同じメッセージが 3 回届きます。
解決策は Post/Redirect/Get パターンです。サーバーは POST を処理し、Location ヘッダー付きの 303 See Other で応答します。ブラウザーはその URL を GET で読み込むので、訪問者が見るページは GET から来たものになり、再読み込みしても害はありません。
- 200 で応答すると、ブラウザーはフォームを送信し、レスポンスのボディをページとして表示します。
- そのページは POST に結びついているので、再読み込みすると再送信を求められ、確認するとデータがもう一度送られます。
- 303 で応答すると、ブラウザーはフォームを送信し、別の URL を読み込むよう指示されます。
- ブラウザーはその URL を GET で読み込みます。再読み込みで繰り返されるのは GET だけなので、何も二重に送られません。
| ステータス | ブラウザーが次にすること | 使う場面 |
|---|---|---|
303 See Other | Location を GET し、ボディは破棄 | フォームの送信が成功したあとは常に |
302 Found | 実際にはブラウザーは GET に切り替えますが、仕様ではメソッドを維持することも許されています | 古いサーバー。303 を推奨 |
307 と 308 | 同じ POST を同じボディで新しい URL に繰り返す | エンドポイントの移転。サンクスページには使わない |
HTML 付きの 200 | ページを表示。再読み込みすると再送信を求められる | エラー付きでフォームを再表示するとき |
HTML 付きの 4xx | エラーページを表示 | 訪問者が戻って直せるエラー |
HTTP/1.1 303 See Other
Location: https://example.com/report-sent
X-Request-Id: req_01k7v2c9t3e8m5q4x6z0a1b2c3
GET /report-sent HTTP/1.1
Host: example.comIkiform の応答のしかた
1 つのエンドポイント https://ikiform.com/f/<formId> が、ブラウザーにもコードにも応答します。ページで応答するか JSON で応答するかは、ブラウザーが自動で付けるヘッダーからリクエストごとに判断します。
| リクエスト | 応答 |
|---|---|
Accept に application/json が含まれる | JSON |
| ボディが JSON | JSON |
Sec-Fetch-Mode: navigate(素のフォーム送信はすべてこれ) | リダイレクトまたはホストされたページ |
それ以外の Sec-Fetch-Mode(fetch の cors など) | JSON |
Sec-Fetch-Mode がなく、Accept に text/html が含まれる | リダイレクトまたはホストされたページ |
| それ以外(curl など) | JSON |
Sec-Fetch-Mode はブラウザーが付けるヘッダーで、ページのスクリプトからは変更できません。タブを遷移させるフォーム送信は navigate を送り、fetch の呼び出しはクロスサイトのリクエストなら cors を送ります。そのため同じフォームでも、素の方法で送信すればリダイレクトが、スクリプトで送信すれば JSON が返り、こちらで何かを切り替える必要はありません。表の全体は JSON かリダイレクトかにあります。
- 素のフォーム送信はタブを遷移させるので、ブラウザーは Sec-Fetch-Mode: navigate を送ります。
- Ikiform はページモードで応答します。成功なら 303 リダイレクト、失敗ならエラーの実際のステータスでホストされたエラーページを返します。
- fetch の呼び出しは Sec-Fetch-Mode: cors を送るか、コードが Accept ヘッダーで JSON を要求します。
- Ikiform は JSON モードで応答します。201 と送信データの ID、またはフィールド ID をキーにしたフィールドエラー付きのエラーエンベロープを返します。
送信が成功したあとの行き先
ブラウザーからの送信が成功すると 303 が返ります。Ikiform は次の順番で行き先を選びます。
- ダッシュボードのフォームの Redirect after submit 設定。設定できるのは自分のワークスペースだけなので、
http:でもhttps:でも、どの URL でも使えます。 - 送信に含まれる
_redirectフィールド(_redirectがなければ_next)。ユーザー名、パスワード、独自のポートを含まないhttps:の URL で、フォームの Allowed domains(許可ドメイン)のどれか、またはそのサブドメインにある場合に限ります。 - それ以外の場合は、
/f/<formId>/thanksにあるホストされたサンクスページです。送信が許可ドメインのhttps:のページから来た場合は、そこへ戻るリンクが付きます。
ルールに反する _redirect はエラーにはならず、単に無視されます。許可ドメインのないフォームは _redirect を完全に無視します。これで、誰かがあなたのフォームを使って訪問者を好きなサイトへ飛ばすことを防げます。サンクスページについては送信後のリダイレクトで詳しく扱い、ルールの全体は HTML フォームにあります。
送信が拒否されたとき
ページモードでは、拒否された送信に対して、エラーの実際の HTTP ステータスでホストされたエラーページが表示されます。検証エラーなら、各問題をフィールドのラベルの下に並べ、送信元のページへのリンクを付けます。ブラウザーの Back(戻る)ボタンなら、たいてい訪問者が入力した内容が残ったままフォームに戻れます。リンクはページを新しく読み込みます。JSON モードでは、同じエラーが 1 つのエンベロープで返ります。すべてのコードはエラーにあります。
{
"ok": false,
"error": {
"code": "validation_failed",
"message": "Some fields are invalid.",
"fields": {
"page": ["Enter a valid URL"],
"details": ["Must be at least 20 characters"]
}
},
"request_id": "req_01k7v2c9t3e8m5q4x6z0a1b2c3"
}スパムだけは、決してエラーのようには見えません。スパムと判定された送信にも本物と同じ 303 か 201 が返るので、ボットは何も学べず、メールも Webhook も送られません。
あなたのスタックでのフォーム
壊れたページを報告する実際のフォームを、すべてのスタックで示します。自分のスタックを選んでください。選択はこのガイドのすべてのコード例に引き継がれます。下のフィールド定義は Ikiform がサーバーで強制するものと同じなので、試してみてください。詳細を短くしたり、アドレスに example と入力したりすると、フォームが受け取るのと同じ 422 が返ります。
プレビュー
Ikiform のバリデーターをブラウザーで実行します。何も送信されません。
スクリプトはまったく使いません。ブラウザーが required、type、minlength をチェックし、フィールドを urlencoded のテキストとして送信し、Ikiform の 303 に従って _redirect のページに移動し、サーバーが送信を拒否したら Ikiform のエラーページを表示します。
<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="post">
<label for="email">Your email</label>
<input id="email" name="email" type="email" autocomplete="email" required />
<label for="page">Page address</label>
<input id="page" name="page" type="url" required placeholder="https://example.com/pricing" />
<label for="severity">How bad is it</label>
<select id="severity" name="severity">
<option value="cosmetic">Looks wrong</option>
<option value="broken">Something doesn't work</option>
<option value="blocking">I can't continue</option>
</select>
<label for="details">What happened</label>
<textarea id="details" name="details" rows="5" minlength="20" maxlength="4000" required></textarea>
<input type="hidden" name="_redirect" value="https://example.com/report-sent" />
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
<button>Send report</button>
</form>サーバーアクションが FormData を受け取り、シークレットキーを付けて JSON として転送し、Ikiform のフィールドエラーを返します。useActionState で各メッセージを入力欄の下に表示し、訪問者が入力した内容を残します。
"use server";
import { headers } from "next/headers";
export interface ReportState {
sent: boolean;
fields: Record<string, string[]>;
message: string;
values: Record<string, string>;
}
const NAMES = ["email", "page", "severity", "details"] as const;
export async function sendReport(_previous: ReportState, formData: FormData): Promise<ReportState> {
const values = Object.fromEntries(NAMES.map((name) => [name, String(formData.get(name) ?? "").trim()]));
const origin = (await headers()).get("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({ ...values, _gotcha: String(formData.get("_gotcha") ?? "") }),
});
const result = await response.json().catch(() => null);
if (result?.ok) return { sent: true, fields: {}, message: "Thanks. We'll look at that page today.", values: {} };
return {
sent: false,
fields: result?.error?.fields ?? {},
message: result?.error?.message ?? "The report didn't go through. Try again.",
values,
};
}"use client";
import { useActionState } from "react";
import { type ReportState, sendReport } from "./actions";
const initial: ReportState = { sent: false, fields: {}, message: "", values: {} };
function FieldError({ id, messages }: { id: string; messages?: string[] }) {
return messages?.length ? <p id={`${id}-error`}>{messages[0]}</p> : null;
}
export function ReportForm() {
const [state, action, pending] = useActionState(sendReport, initial);
if (state.sent) return <p role="status">{state.message}</p>;
return (
<form action={action}>
<label htmlFor="email">Your email</label>
<input id="email" name="email" type="email" required defaultValue={state.values.email}
aria-invalid={Boolean(state.fields.email)} aria-describedby="email-error" />
<FieldError id="email" messages={state.fields.email} />
<label htmlFor="page">Page address</label>
<input id="page" name="page" type="url" required defaultValue={state.values.page}
aria-invalid={Boolean(state.fields.page)} aria-describedby="page-error" />
<FieldError id="page" messages={state.fields.page} />
<label htmlFor="severity">How bad is it</label>
<select id="severity" name="severity" defaultValue={state.values.severity ?? "broken"}>
<option value="cosmetic">Looks wrong</option>
<option value="broken">Something doesn't work</option>
<option value="blocking">I can't continue</option>
</select>
<label htmlFor="details">What happened</label>
<textarea id="details" name="details" rows={5} required minLength={20} defaultValue={state.values.details}
aria-invalid={Boolean(state.fields.details)} aria-describedby="details-error" />
<FieldError id="details" messages={state.fields.details} />
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{state.message ? <p role="alert">{state.message}</p> : null}
<button disabled={pending}>{pending ? "Sending" : "Send report"}</button>
</form>
);
}accept: "form" を指定すると、不正なメールアドレスや URL は、サーバーから何かが出ていく前に Zod が拒否し、isInputError でそのメッセージをフィールドごとに取り出せます。Ikiform 自身の 422 フィールドエラーも、同じ形のアクションデータとして返ります。
import { defineAction } from "astro:actions";
import { getSecret } from "astro:env/server";
import { z } from "astro/zod";
export const server = {
reportPage: defineAction({
accept: "form",
input: z.object({
email: z.email(),
page: z.url(),
severity: z.enum(["cosmetic", "broken", "blocking"]),
details: z.string().min(20, "Tell us a little more, at least 20 characters."),
_gotcha: z.string().nullish(),
}),
handler: async (input, context) => {
const origin = context.request.headers.get("origin");
const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${getSecret("IKIFORM_SECRET_KEY")}`,
...(origin ? { Origin: origin } : {}),
},
body: JSON.stringify(input),
});
const result = await response.json();
if (result.ok) return { sent: true, fields: {} as Record<string, string[]> };
return { sent: false, fields: (result.error.fields ?? {}) as Record<string, string[]> };
},
}),
};---
import { actions, isInputError } from "astro:actions";
export const prerender = false;
const result = Astro.getActionResult(actions.reportPage);
const fields = result?.error && isInputError(result.error) ? result.error.fields : (result?.data?.fields ?? {});
---
{result?.data?.sent ? (
<p role="status">Thanks. We'll look at that page today.</p>
) : (
<form method="POST" action={actions.reportPage}>
<label for="email">Your email</label>
<input id="email" name="email" type="email" required />
{fields.email && <p>{fields.email[0]}</p>}
<label for="page">Page address</label>
<input id="page" name="page" type="url" required />
{fields.page && <p>{fields.page[0]}</p>}
<label for="severity">How bad is it</label>
<select id="severity" name="severity">
<option value="cosmetic">Looks wrong</option>
<option value="broken" selected>Something doesn't work</option>
<option value="blocking">I can't continue</option>
</select>
<label for="details">What happened</label>
<textarea id="details" name="details" rows="5" minlength="20" required></textarea>
{fields.details && <p>{fields.details[0]}</p>}
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
<button>Send report</button>
</form>
)}POST のサーバー関数が .validator() で FormData を読み、フィールドエラーを普通のデータとして返すので、コンポーネントは何もキャッチせずにそれを表示できます。
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";
type Outcome = { sent: boolean; fields: Record<string, string[]>; message: string };
const sendReport = createServerFn({ method: "POST" })
.validator((data: unknown) => {
if (!(data instanceof FormData)) throw new Error("Expected form data");
const read = (name: string) => String(data.get(name) ?? "").trim();
return {
email: read("email"),
page: read("page"),
severity: read("severity"),
details: read("details"),
_gotcha: read("_gotcha"),
};
})
.handler(async ({ data }): Promise<Outcome> => {
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();
if (result.ok) return { sent: true, fields: {}, message: "Thanks. We'll look at that page today." };
return { sent: false, fields: result.error.fields ?? {}, message: String(result.error.message) };
});
export const Route = createFileRoute("/report")({ component: Report });
function Report() {
const send = useServerFn(sendReport);
const [outcome, setOutcome] = useState<Outcome | null>(null);
const [pending, setPending] = useState(false);
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setPending(true);
try {
setOutcome(await send({ data: new FormData(event.currentTarget) }));
} catch {
setOutcome({ sent: false, fields: {}, message: "The report didn't go through. Try again." });
} finally {
setPending(false);
}
}
if (outcome?.sent) return <p role="status">{outcome.message}</p>;
const fields = outcome?.fields ?? {};
return (
<form method="post" onSubmit={onSubmit}>
<label htmlFor="email">Your email</label>
<input id="email" name="email" type="email" required aria-invalid={Boolean(fields.email)} />
{fields.email ? <p>{fields.email[0]}</p> : null}
<label htmlFor="page">Page address</label>
<input id="page" name="page" type="url" required aria-invalid={Boolean(fields.page)} />
{fields.page ? <p>{fields.page[0]}</p> : null}
<label htmlFor="severity">How bad is it</label>
<select id="severity" name="severity" defaultValue="broken">
<option value="cosmetic">Looks wrong</option>
<option value="broken">Something doesn't work</option>
<option value="blocking">I can't continue</option>
</select>
<label htmlFor="details">What happened</label>
<textarea id="details" name="details" rows={5} minLength={20} required aria-invalid={Boolean(fields.details)} />
{fields.details ? <p>{fields.details[0]}</p> : null}
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{outcome?.message ? <p role="alert">{outcome.message}</p> : null}
<button disabled={pending}>{pending ? "Sending" : "Send report"}</button>
</form>
);
}ルートの action がフォームの本当の送信先なので、<Form> はハイドレーション前でも動きます。data() は、Ikiform の 422 ステータスを、フィールドエラーと入力された値と一緒にそのまま渡します。
import { data, Form, useNavigation } from "react-router";
import type { Route } from "./+types/report";
export async function action({ request }: Route.ActionArgs) {
const form = await request.formData();
const values = {
email: String(form.get("email") ?? "").trim(),
page: String(form.get("page") ?? "").trim(),
severity: String(form.get("severity") ?? ""),
details: String(form.get("details") ?? "").trim(),
};
const origin = request.headers.get("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({ ...values, _gotcha: String(form.get("_gotcha") ?? "") }),
});
const result = await response.json();
if (result.ok) return { sent: true as const };
return data(
{
sent: false as const,
fields: (result.error.fields ?? {}) as Record<string, string[]>,
message: String(result.error.message),
values,
},
{ status: response.status },
);
}
export default function Report({ actionData }: Route.ComponentProps) {
const sending = useNavigation().state === "submitting";
if (actionData?.sent) return <p role="status">Thanks. We'll look at that page today.</p>;
const fields = actionData?.fields ?? {};
const values = actionData?.values;
return (
<Form method="post">
<label htmlFor="email">Your email</label>
<input id="email" name="email" type="email" required defaultValue={values?.email} />
{fields.email ? <p>{fields.email[0]}</p> : null}
<label htmlFor="page">Page address</label>
<input id="page" name="page" type="url" required defaultValue={values?.page} />
{fields.page ? <p>{fields.page[0]}</p> : null}
<label htmlFor="severity">How bad is it</label>
<select id="severity" name="severity" defaultValue={values?.severity ?? "broken"}>
<option value="cosmetic">Looks wrong</option>
<option value="broken">Something doesn't work</option>
<option value="blocking">I can't continue</option>
</select>
<label htmlFor="details">What happened</label>
<textarea id="details" name="details" rows={5} minLength={20} required defaultValue={values?.details} />
{fields.details ? <p>{fields.details[0]}</p> : null}
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{actionData && !Object.keys(fields).length ? <p role="alert">{actionData.message}</p> : null}
<button disabled={sending}>{sending ? "Sending" : "Send report"}</button>
</Form>
);
}既定のフォームアクションは、Ikiform のステータスとフィールドのエラーを付けて fail() を返します。JavaScript がなければページがそれを付けて再レンダリングされ、use:enhance があればその場で更新されます。$app/env/private から読めるよう、src/env.ts で defineEnvVars を使って IKIFORM_SECRET_KEY を宣言します。
import { fail } from "@sveltejs/kit";
import { IKIFORM_SECRET_KEY } from "$app/env/private";
import type { Actions } from "./$types";
export const actions = {
default: async ({ request, fetch, url }) => {
const form = await request.formData();
const values = {
email: String(form.get("email") ?? "").trim(),
page: String(form.get("page") ?? "").trim(),
severity: String(form.get("severity") ?? ""),
details: String(form.get("details") ?? "").trim(),
};
const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${IKIFORM_SECRET_KEY}`,
Origin: request.headers.get("origin") ?? url.origin,
},
body: JSON.stringify({ ...values, _gotcha: String(form.get("_gotcha") ?? "") }),
});
const result = await response.json();
if (!result.ok) {
return fail(response.status, { values, fields: (result.error.fields ?? {}) as Record<string, string[]> });
}
return { sent: true };
},
} satisfies Actions;<script lang="ts">
import { enhance } from "$app/forms";
import type { PageProps } from "./$types";
let { form }: PageProps = $props();
let fields = $derived(form?.fields ?? {});
const SEVERITIES = [
["cosmetic", "Looks wrong"],
["broken", "Something doesn't work"],
["blocking", "I can't continue"],
];
</script>
{#if form?.sent}
<p role="status">Thanks. We'll look at that page today.</p>
{:else}
<form method="POST" use:enhance>
<label for="email">Your email</label>
<input id="email" name="email" type="email" required value={form?.values?.email ?? ""} />
{#if fields.email}<p>{fields.email[0]}</p>{/if}
<label for="page">Page address</label>
<input id="page" name="page" type="url" required value={form?.values?.page ?? ""} />
{#if fields.page}<p>{fields.page[0]}</p>{/if}
<label for="severity">How bad is it</label>
<select id="severity" name="severity">
{#each SEVERITIES as [value, label]}
<option {value} selected={(form?.values?.severity ?? "broken") === value}>{label}</option>
{/each}
</select>
<label for="details">What happened</label>
<textarea id="details" name="details" rows="5" minlength="20" required value={form?.values?.details ?? ""}></textarea>
{#if fields.details}<p>{fields.details[0]}</p>{/if}
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
<button>Send report</button>
</form>
{/if}サーバールートは NUXT_IKIFORM_SECRET_KEY から設定される runtimeConfig.ikiformSecretKey を使ってレポートを転送します。フィールドのエラーを data に入れて createError を投げ、$fetch がそれをクライアントに返します。
export default defineEventHandler(async (event) => {
const body = await readBody<Record<string, string>>(event);
const { ikiformSecretKey } = useRuntimeConfig(event);
const origin = getRequestHeader(event, "origin");
const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${ikiformSecretKey}`,
...(origin ? { Origin: origin } : {}),
},
body: JSON.stringify({
email: body.email ?? "",
page: body.page ?? "",
severity: body.severity ?? "",
details: body.details ?? "",
_gotcha: body._gotcha ?? "",
}),
});
const result = await response.json();
if (!result.ok) {
throw createError({
status: response.status,
message: String(result.error.message),
data: { fields: result.error.fields ?? {} },
});
}
return { sent: true };
});<script setup lang="ts">
const sent = ref(false);
const sending = ref(false);
const fields = ref<Record<string, string[]>>({});
async function send(event: Event) {
const form = event.target as HTMLFormElement;
sending.value = true;
fields.value = {};
try {
await $fetch("/api/report", { method: "POST", body: Object.fromEntries(new FormData(form)) });
sent.value = true;
} catch (failure) {
const reply = failure as { data?: { data?: { fields?: Record<string, string[]> } } };
fields.value = reply.data?.data?.fields ?? { details: ["The report didn't go through. Try again."] };
} finally {
sending.value = false;
}
}
</script>
<template>
<p v-if="sent" role="status">Thanks. We'll look at that page today.</p>
<form v-else method="post" action="/api/report" @submit.prevent="send">
<label for="email">Your email</label>
<input id="email" name="email" type="email" required />
<p v-if="fields.email">{{ fields.email[0] }}</p>
<label for="page">Page address</label>
<input id="page" name="page" type="url" required />
<p v-if="fields.page">{{ fields.page[0] }}</p>
<label for="severity">How bad is it</label>
<select id="severity" name="severity">
<option value="cosmetic">Looks wrong</option>
<option value="broken" selected>Something doesn't work</option>
<option value="blocking">I can't continue</option>
</select>
<label for="details">What happened</label>
<textarea id="details" name="details" rows="5" minlength="20" required />
<p v-if="fields.details">{{ fields.details[0] }}</p>
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
<button :disabled="sending">{{ sending ? "Sending" : "Send report" }}</button>
</form>
</template>シングルページアプリはブラウザーから送信するので、ここにシークレットキーはありません。Allowed domains(許可ドメイン)がフォームを守ります。Accept: application/json を付けると、リダイレクトではなく JSON の応答が返ります。
import { type FormEvent, useState } from "react";
const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";
const SEVERITIES = [
["cosmetic", "Looks wrong"],
["broken", "Something doesn't work"],
["blocking", "I can't continue"],
];
export function ReportForm() {
const [sent, setSent] = useState(false);
const [sending, setSending] = useState(false);
const [fields, setFields] = useState<Record<string, string[]>>({});
const [problem, setProblem] = useState("");
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setSending(true);
setFields({});
setProblem("");
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) setSent(true);
else if (result.error.fields) setFields(result.error.fields);
else setProblem(result.error.message);
} catch {
setProblem("Couldn't reach the server. Check your connection and try again.");
} finally {
setSending(false);
}
}
if (sent) return <p role="status">Thanks. We'll look at that page today.</p>;
return (
<form action={ENDPOINT} method="post" onSubmit={onSubmit}>
<label htmlFor="email">Your email</label>
<input id="email" name="email" type="email" required aria-invalid={Boolean(fields.email)} />
{fields.email ? <p>{fields.email[0]}</p> : null}
<label htmlFor="page">Page address</label>
<input id="page" name="page" type="url" required aria-invalid={Boolean(fields.page)} />
{fields.page ? <p>{fields.page[0]}</p> : null}
<label htmlFor="severity">How bad is it</label>
<select id="severity" name="severity" defaultValue="broken">
{SEVERITIES.map(([value, label]) => (
<option key={value} value={value}>{label}</option>
))}
</select>
<label htmlFor="details">What happened</label>
<textarea id="details" name="details" rows={5} minLength={20} required aria-invalid={Boolean(fields.details)} />
{fields.details ? <p>{fields.details[0]}</p> : null}
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{problem ? <p role="alert">{problem}</p> : null}
<button disabled={sending}>{sending ? "Sending" : "Send report"}</button>
</form>
);
}同じブラウザーからの送信を Vue コンポーネントで書いたものです。フォームには本物の action と method が残っているので、スクリプトの読み込みに失敗してもプレーンな方法で送信されます。
<script setup lang="ts">
import { ref } from "vue";
const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";
const sent = ref(false);
const sending = ref(false);
const fields = ref<Record<string, string[]>>({});
const problem = ref("");
async function send(event: SubmitEvent) {
sending.value = true;
fields.value = {};
problem.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) sent.value = true;
else if (result.error.fields) fields.value = result.error.fields;
else problem.value = result.error.message;
} catch {
problem.value = "Couldn't reach the server. Check your connection and try again.";
} finally {
sending.value = false;
}
}
</script>
<template>
<p v-if="sent" role="status">Thanks. We'll look at that page today.</p>
<form v-else :action="ENDPOINT" method="post" @submit.prevent="send">
<label for="email">Your email</label>
<input id="email" name="email" type="email" required :aria-invalid="Boolean(fields.email)" />
<p v-if="fields.email">{{ fields.email[0] }}</p>
<label for="page">Page address</label>
<input id="page" name="page" type="url" required :aria-invalid="Boolean(fields.page)" />
<p v-if="fields.page">{{ fields.page[0] }}</p>
<label for="severity">How bad is it</label>
<select id="severity" name="severity">
<option value="cosmetic">Looks wrong</option>
<option value="broken" selected>Something doesn't work</option>
<option value="blocking">I can't continue</option>
</select>
<label for="details">What happened</label>
<textarea id="details" name="details" rows="5" minlength="20" required :aria-invalid="Boolean(fields.details)" />
<p v-if="fields.details">{{ fields.details[0] }}</p>
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
<p v-if="problem" role="alert">{{ problem }}</p>
<button :disabled="sending">{{ sending ? "Sending" : "Send report" }}</button>
</form>
</template>IKIFORM_SECRET_KEY はサーバー専用の環境変数に置いてください。各サーバー版は、ブラウザーの Origin ヘッダーがあればそれを転送します。サーバー側の fetch は Origin を送らず、Ikiform はこれを使って、プランのないワークスペースでの無料の localhost テストを見分けるからです。
- どの版にも本物の
methodがあり、Ikiform に直接送信する版には本物のactionもあります。スクリプトがオフでもフォームは送信され、303を受け取ります。 - 各入力欄の
nameが、定義のフィールドidになります。pageではなくurlのように一致しないと、Unknown field "url"付きの422になります。 - 非表示の
_gotcha入力欄がハニーポットです。これを埋めたボットは、何も知らせずにスパムとして保存されます。 - JavaScript 版は、各フィールドの最初のメッセージを使って
error.fieldsを各入力欄の下に表示します。プレーンな HTML 版はそれを Ikiform のエラーページに任せます。
フレームワークごとの詳細はドキュメントにあります。Next.js、Astro、SvelteKit、Nuxt、React、Vue、サーバーからを参照してください。
2 層のバリデーション
ブラウザーのバリデーションとサーバーのバリデーションは役割が違います。ブラウザーの制約チェック(required、type="email"、minlength、pattern)はすぐにフィードバックを返しますが、誰でも迂回できます。ボットは直接送信しますし、スクリプトはチェックを一切実行しない form.submit() を呼べます。ルールが本当に守られるのはサーバーだけです。
| ルール | ブラウザー側 | Ikiform のフィールド定義 |
|---|---|---|
| 必須 | required | "required": true |
| メールアドレス | type="email" | "type": "email"、任意で allowed_domains |
| URL | type="url" | "type": "url"、任意で https_only |
| 長さ | minlength, maxlength | validation.min_length, max_length |
| 選択肢のみ | <select>(ボットは無視する) | options。それ以外は拒否 |
| 余分なフィールドなし | 不可能 | 定義にないフィールドはすべて拒否 |
定義がなければ、Ikiform はどんなフィールドでも受け付け、届いたまま保存します。定義を公開すると検証が行われ、数値やチェックボックスは本物の数値と真偽値として保存され、何かが保存される前に 422 validation_failed で送信が拒否されます。両方の層で同じことを伝える方法はバリデーションで説明しています。
JavaScript で送信する
訪問者をページにとどめるには、submit を監視して preventDefault() を呼び、new FormData(form) を fetch で送ります。最初からうまく動くかどうかは、いくつかの細かい点で決まります。
- ボディが
FormDataのときは、Content-Typeを自分で設定しないでください。ブラウザーが境界(boundary)付きでmultipart/form-dataを設定します。手書きのヘッダーには境界がないので、ボディを解析できなくなります。 Accept: application/jsonを送ります。Ikiform はいずれにせよSec-Fetch-Modeから JSON を選びますが、ヘッダーで意図が明確になりますし、これはブラウザーがプリフライトなしでクロスサイト送信できるリクエストヘッダーの 1 つです。FormDataやURLSearchParamsのボディは単純なクロスサイトリクエストなので、プリフライトはありません。Content-Type: application/json付きの JSON ボディだと先にプリフライトが発生します。Ikiform はそれに応答しますが、往復が 1 回増えます。form.submit()はバリデーションとsubmitイベントを飛ばします。form.requestSubmit()は、クリックと同じように両方を実行します。- クリックされたボタンの
nameとvalueが重要なら、new FormData(form, event.submitter)のように 2 番目の引数として渡します。
読み込み状態、フォーカス、ネットワークエラーまで含めた完全なパターンはページを再読み込みせずに送信にあります。エンドポイントが受け付けるすべてのヘッダーとボディ形式は、ドキュメントの JavaScript と fetch に載っています。
状況に合わせて選ぶ
以下の各ページは、送信の一部分を取り上げて上の概要より深く掘り下げ、そのケースのコードを示します。
- ページを再読み込みせずにfetch と FormData でフォームを送り、訪問者をページにとどめる。フィールドのエラー、読み込み状態、リトライ、スクリプト失敗時のフォールバック付き。
- 送信後のリダイレクトフォーム送信後に訪問者を自分のサンクスページへ送る方法。303 リダイレクト、_redirect フィールド、許可されるドメイン、再読み込みの挙動を解説します。
- action 属性フォームの action 属性の役割、相対値や空の値の解決のされ方、formaction で 1 つのフォームを 2 か所に送る方法、静的ホストが 405 を返す理由を解説します。
- フォームのバリデーションrequired、type、pattern、min でフォームを検証し、:user-invalid でエラーをスタイリングし、同じルールをフィールド定義でサーバー側にも適用します。
動作を確認する
フォームごとに 1 回、プライベートウィンドウでこれを一通り確認してください。Ikiform に無料プランはありません。テストは無料です。プランのないワークスペースでも、localhost からのテスト送信を月 50 件まで受け付けます。本番運用するには、500 件で月 $6 からの Pro を選ぶか、買い切りにしてください。10,000 件で $49、または 50,000 件で $149 で、クレジットに有効期限はありません。料金を参照してください。
- フォームの Allowed domains に自分のサイトのドメインを追加し、開発中は
localhostも追加します。空のままにすると、どこからの送信も受け付けます。 - ブラウザーの開発者ツールで JavaScript をオフにしてフォームを送信します。それでも
303でサンクスページに着くはずです。 - JavaScript をオンにして Network パネルを開き、もう一度送信します。
/f/<formId>へのリクエストは JSON 付きの201を返し、ページは再読み込みされないはずです。 - 短すぎるメッセージで送信します。プレーンな送信ではフィールドのラベル付きで Ikiform のエラーページが表示され、スクリプト版ではフィールドの下にメッセージが表示されます。
- サンクスページで再読み込みを押します。何も再送信されず、2 件目の送信データも現れないはずです。
- ダッシュボードで Submissions を開きます。送ったすべてのフィールドを含む新しい送信データがあります。
- ハニーポットを埋めて
_gotcha=botで送信します。成功の応答は返りますが、送信データはスパムとして保存され、メールも Webhook も送られません。
トラブルシューティング
| 問題 | 対処法 |
|---|---|
送信後に 405 Method Not Allowed | フォームが自分の静的ホストに送信しており、そこは POST を受け付けられません。action を確認してください。相対パスや空ではなく、完全な https://ikiform.com/f/<formId> URL でなければなりません。 |
エンドポイントをタブで開くと 404 not_found が表示される | 想定どおりです。タブは GET を送り、エンドポイントは POST しか受け付けません。フォームの method が post か確認してください。method のないフォームは、フィールドを URL に入れて GET で送ります。 |
415 unsupported_media_type | フォームに enctype="text/plain" があるか、スクリプトが Ikiform の読めない形式で送っています。enctype を削除するか、multipart/form-data を使ってください。 |
422 validation_failed | 送信がフォームのフィールド定義に合っていません。error.fields に各フィールドと理由が示されます。入力欄の name をフィールド ID に合わせてください。 |
| ブラウザーが自分のページではなく Ikiform のサンクスページに移動する | _redirect は、ドメインが Allowed domains にあり、https: の URL でないと無視されます。ドメインを追加するか、代わりにフォーム設定で Redirect after submit を設定してください。 |
| 送信データにフィールドが欠けている | name がない、disabled になっている、またはチェックされていないチェックボックスです。name を付け、disabled の代わりに readonly を使うか、checkbox フィールドを定義して未チェックのボックスを false として保存させます。 |
| 再読み込みでフォームの再送信を求められる | ページが POST に 200 で応答しています。Ikiform に直接送信すれば必ず 303 が返ります。間に自分のサーバーがあるなら、送信を処理した後にリダイレクトしてください。 |
403 origin_not_allowed が返る、またはすべての送信がスパムになる | ページのドメインがフォームの Allowed domains にありません。ドメインと、ローカルテスト用の localhost を追加してください。登録したドメインのサブドメインは許可されます。 |
よくある質問
フォームのデータを受け取る URL です。相対値はページのアドレスを基準に解決され、action のないフォームはそのページ自身に送信します。送信ボタンは formaction でこれを上書きできます。
お問い合わせ、登録、注文フォームなど、個人データを送ったり状態を変えたりするものには POST を使います。GET は検索や絞り込みのフォーム向けで、フィールドが共有可能な URL に含まれるべき場合に使います。
表示しているページが POST への直接の応答なので、ブラウザーがそれを繰り返すか尋ねるのです。成功した POST には、代わりにサンクスページへの 303 リダイレクトで応答しましょう。Ikiform はブラウザーからのすべての送信でそうしています。
フォーム単体では送れません。フォームが送るのは urlencoded か multipart のデータです。JSON を送るには、submit ハンドラーで FormData を使ってフォームを読み、JSON.stringify(Object.fromEntries(data)) を fetch で送信します。Ikiform は 3 つすべてを受け付けます。
いいえ。人の助けにはなりますが、ボットは直接送信してそれを迂回します。サーバーでも検証してください。Ikiform ならフィールドを定義すれば、すべての送信が保存前にそれに照らしてチェックされます。
POST を受け取る何かは必要ですが、自分のサーバーである必要はありません。フォームの action を Ikiform のようなフォームバックエンドに向ければ、ページは完全に静的なままでかまいません。
まだできません。ファイルを含む送信は 422 files_not_supported で拒否されます。代わりに、共有ドライブの URL などファイルへのリンクを尋ねてください。
手順で詰まったときや、あなたの状況に合うガイドがほしいときはメールでどうぞ: hello@ikiform.com