# HTML フォームの送信の仕組み

> フォームが送信されたときにブラウザーが行うこと。どのフィールドを送り、どうエンコードし、サーバーは何を返すべきか、そして Ikiform がどう応答するかを解説します。

公開日 2026-10-25 · https://ikiform.com/ja/guides/html-form-submission

送信ボタンが押されると、ブラウザーはフォームの制約をチェックし、name があり無効になっていないコントロールをすべて集め、フォームの `enctype` でエンコードして、フォームの `method` で `action` の URL に送ります。サーバーは通常のページ読み込みと同じように応答します。行儀のよいサーバーは、成功した POST に `303` リダイレクトで応答するので、再読み込みしてもフォームが二重に送信されません。`action` を Ikiform などのフォームバックエンドに向ければ、自前のサーバーなしで、そのリダイレクト、フィールドの検証、スパムフィルタリングが手に入ります。

HTML フォームは JavaScript より古く、今でもウェブページからデータを送るいちばん確実な方法です。スクリプトがオフでも、遅いスマートフォンでも、マークアップを貼り付けられるどのビルダーでも動きます。フォームのバグの多くは、ブラウザーが自分で何をしているかを知らないことから生まれます。このガイドでは送信を 1 ステップずつたどり、そのあとでフォームバックエンドがどう組み込まれるかを示します。

## 送信ボタンを押すと何が起きるか

送信は、ボディが付いた通常のナビゲーションです。ブラウザーは、ページから何かが出ていく前に 4 つのことを行い、そのあとリクエストをネットワークに渡して、返ってきたものを読み込みます。

図: 送信ボタンのクリックから次のページまでにブラウザーが行うこと

1. 送信ボタン、テキストフィールドでの Enter キー、または form.requestSubmit() で送信が始まり、submit イベントが発生します。
2. フォームに novalidate がない限り、ブラウザーはすべてのコントロールの制約をチェックします。最初の無効なコントロールにメッセージが表示され、何も送信されません。
3. ブラウザーは、name があり無効になっていないすべてのコントロールからフォームデータセットを作ります。
4. そのデータセットをフォームの enctype でエンコードします。指定しなければ urlencoded です。
5. フォームの method で action の URL にナビゲートし、ボディを送ります。
6. サーバーが応答します。303 はブラウザーに別の URL を GET で読み込ませ、それが訪問者が最終的に見るページになります。

線より上のことは、ネットワークをまったく使わずに起きます。submit イベントのリスナーなら、最初のステップで流れを止め、fetch で処理を引き継げます。

## リクエストを決める属性

`<form>` の 5 つの属性が、データをどこへどう送るかを決めます。どれも、対応する `form*` 属性で 1 つのボタンだけ上書きできます。こうして、1 つのフォームに動作の違う **Send** と **Save draft** のボタンを置けます。

| 属性 | デフォルト | 役割 | ボタンでの上書き |
| --- | --- | --- | --- |
| `action` | ページ自身の URL | データを受け取る URL。相対値はページを基準に解決されます。[action 属性](https://ikiform.com/ja/guides/html-form-submission/action-attribute.md)を参照してください | `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` がデータに加わるので、サーバーはどちらが押されたかがわかります。

`2 つの送信ボタン`:

```html
<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 行で、スペースは `+` に、安全な文字以外はすべてパーセントエンコードされます。これは、下にある報告フォームをブラウザーが送ったときの実際のリクエストです。

`application/x-www-form-urlencoded`:

```http
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` で拒否します。

`multipart/form-data`:

```http
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--
```

> エンコードが何であれ、ページは UTF-8 にしてください。Ikiform はすべてのボディを UTF-8 としてデコードし、ほかの文字コードは `415 unsupported_charset` で拒否します。ファイルアップロードにはまだ対応していません。ファイル付きの送信には `422 files_not_supported` が返ります。

## フォームを送信する 4 つの方法

通常の送信は選択肢の 1 つにすぎません。以下のどの方法も、場面によっては妥当な選択です。違うのは、誰が応答を処理するかと、スクリプトがオフのときに何が起きるかです。

| 方法 | 得意なこと | トレードオフ |
| --- | --- | --- |
| リダイレクト付きの素の `method="post"` | JavaScript なしで、どのビルダーでも動きます。読み込み中の状態、戻る、再読み込みはブラウザーが処理します | ページ全体の読み込み。フィールドエラーには、サーバーで描画したページかバックエンドのエラーページが必要です |
| `FormData` を使った `fetch` | 再読み込みなし。成功時や各フィールドエラーの見せ方を自分で決められます | 読み込み中の状態、ネットワークエラー、フォーカスを自分で扱います。気をつけないと、スクリプトがオフのときに壊れます。[ページを再読み込みせずに送信する](https://ikiform.com/ja/guides/html-form-submission/without-page-reload.md)を参照してください |
| Next.js のサーバーアクションや SvelteKit のフォームアクションなど、フレームワークのフォームアクション | 送信を最初に受け取るのは自分のサーバーで、同じフォームがハイドレーションの前後どちらでも動きます | サーバーのランタイムが必要で、バックエンドへのリクエストは自分のサーバーから出ていくので、レート制限やオリジンのチェックの扱いが変わります |
| `method="get"` | 検索やフィルターのフォーム。結果に共有できる URL が付きます | フィールドが URL、履歴、サーバーログに残ります。個人データや状態を変更するものには絶対に使わないでください |

まず素の送信から始め、その上に `fetch` を足してください。そうすれば、スクリプトの読み込みに失敗してもフォームは動きます。これは多くのアナリティクスが示すよりも頻繁に起きます。

## サーバーは何を返すべきか

ブラウザーは、POST への応答を新しいページとして扱います。そのページが応答そのもの（ステータス `200`）だと、ブラウザーはそれが POST から来たことを覚えています。再読み込みするとフォームを再送信するか尋ねられ、戻ると進むでも同じことをするブラウザーがあります。こうして二重に支払いが起き、サポートに同じメッセージが 3 回届きます。

解決策は Post/Redirect/Get パターンです。サーバーは POST を処理し、`Location` ヘッダー付きの `303 See Other` で応答します。ブラウザーはその URL を GET で読み込むので、訪問者が見るページは GET から来たものになり、再読み込みしても害はありません。

図: POST にページで応答する場合と 303 リダイレクトで応答する場合の比較

1. 200 で応答すると、ブラウザーはフォームを送信し、レスポンスのボディをページとして表示します。
2. そのページは POST に結びついているので、再読み込みすると再送信を求められ、確認するとデータがもう一度送られます。
3. 303 で応答すると、ブラウザーはフォームを送信し、別の URL を読み込むよう指示されます。
4. ブラウザーはその URL を GET で読み込みます。再読み込みで繰り返されるのは GET だけなので、何も二重に送られません。

Ikiform はブラウザーからの送信に常に 303 で応答するので、フォームは何もしなくてもこの動作になります。

| ステータス | ブラウザーが次にすること | 使う場面 |
| --- | --- | --- |
| `303 See Other` | `Location` を GET し、ボディは破棄 | フォームの送信が成功したあとは常に |
| `302 Found` | 実際にはブラウザーは GET に切り替えますが、仕様ではメソッドを維持することも許されています | 古いサーバー。`303` を推奨 |
| `307` と `308` | 同じ POST を同じボディで新しい URL に繰り返す | エンドポイントの移転。サンクスページには使わない |
| HTML 付きの `200` | ページを表示。再読み込みすると再送信を求められる | エラー付きでフォームを再表示するとき |
| HTML 付きの `4xx` | エラーページを表示 | 訪問者が戻って直せるエラー |

`Post/Redirect/Get`:

```http
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.com
```

## Ikiform の応答のしかた

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 かリダイレクトか](https://ikiform.com/ja/docs/how-it-works.md#response-modes)にあります。

図: 同じ Ikiform のエンドポイントが、素のフォーム送信と fetch の呼び出しに応答する様子

1. 素のフォーム送信はタブを遷移させるので、ブラウザーは Sec-Fetch-Mode: navigate を送ります。
2. Ikiform はページモードで応答します。成功なら 303 リダイレクト、失敗ならエラーの実際のステータスでホストされたエラーページを返します。
3. fetch の呼び出しは Sec-Fetch-Mode: cors を送るか、コードが Accept ヘッダーで JSON を要求します。
4. Ikiform は JSON モードで応答します。201 と送信データの ID、またはフィールド ID をキーにしたフィールドエラー付きのエラーエンベロープを返します。

モードを設定する必要はありません。リクエストの送り方で決まります。

### 送信が成功したあとの行き先

ブラウザーからの送信が成功すると `303` が返ります。Ikiform は次の順番で行き先を選びます。

1. ダッシュボードのフォームの **Redirect after submit** 設定。設定できるのは自分のワークスペースだけなので、`http:` でも `https:` でも、どの URL でも使えます。
2. 送信に含まれる `_redirect` フィールド（`_redirect` がなければ `_next`）。ユーザー名、パスワード、独自のポートを含まない `https:` の URL で、フォームの **Allowed domains**（許可ドメイン）のどれか、またはそのサブドメインにある場合に限ります。
3. それ以外の場合は、`/f/<formId>/thanks` にあるホストされたサンクスページです。送信が許可ドメインの `https:` のページから来た場合は、そこへ戻るリンクが付きます。

ルールに反する `_redirect` はエラーにはならず、単に無視されます。許可ドメインのないフォームは `_redirect` を完全に無視します。これで、誰かがあなたのフォームを使って訪問者を好きなサイトへ飛ばすことを防げます。サンクスページについては[送信後のリダイレクト](https://ikiform.com/ja/guides/html-form-submission/redirect-after-submit.md)で詳しく扱い、ルールの全体は [HTML フォーム](https://ikiform.com/ja/docs/html.md#redirect-rules)にあります。

### 送信が拒否されたとき

ページモードでは、拒否された送信に対して、エラーの実際の HTTP ステータスでホストされたエラーページが表示されます。検証エラーなら、各問題をフィールドのラベルの下に並べ、送信元のページへのリンクを付けます。ブラウザーの **Back**（戻る）ボタンなら、たいてい訪問者が入力した内容が残ったままフォームに戻れます。リンクはページを新しく読み込みます。JSON モードでは、同じエラーが 1 つのエンベロープで返ります。すべてのコードは[エラー](https://ikiform.com/ja/docs/errors.md)にあります。

`JSON モードでの 422`:

```json
{
  "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` が返ります。

フォーム: Report a broken page

- **メールアドレス** (`email`, email, 必須)
- **ページのアドレス** (`page`, url, 必須)
- **どのくらい深刻ですか** (`severity`, select, 必須)
- **何が起きましたか** (`details`, textarea, 必須)

この定義をダッシュボードのフォームの **Fields** タブに貼り付けると、Ikiform がすべての送信をこれに照らして検証します。[フィールドと検証](https://ikiform.com/ja/docs/fields.md)を参照してください。

**HTML**

スクリプトはまったく使いません。ブラウザーが `required`、`type`、`minlength` をチェックし、フィールドを urlencoded のテキストとして送信し、Ikiform の `303` に従って `_redirect` のページに移動し、サーバーが送信を拒否したら Ikiform のエラーページを表示します。

`report.html`:

```html
<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>
```

**Next.js**

サーバーアクションが `FormData` を受け取り、シークレットキーを付けて JSON として転送し、Ikiform のフィールドエラーを返します。`useActionState` で各メッセージを入力欄の下に表示し、訪問者が入力した内容を残します。

`app/report/actions.ts`:

```ts
"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,
  };
}
```

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

```tsx
"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>
  );
}
```

**Astro**

`accept: "form"` を指定すると、不正なメールアドレスや URL は、サーバーから何かが出ていく前に Zod が拒否し、`isInputError` でそのメッセージをフィールドごとに取り出せます。Ikiform 自身の `422` フィールドエラーも、同じ形のアクションデータとして返ります。

`src/actions/index.ts`:

```ts
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[]> };
    },
  }),
};
```

`src/pages/report.astro`:

```astro
---
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>
)}
```

**TanStack Start**

POST のサーバー関数が `.validator()` で `FormData` を読み、フィールドエラーを普通のデータとして返すので、コンポーネントは何もキャッチせずにそれを表示できます。

`src/routes/report.tsx`:

```tsx
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>
  );
}
```

**React Router**

ルートの `action` がフォームの本当の送信先なので、`<Form>` はハイドレーション前でも動きます。`data()` は、Ikiform の `422` ステータスを、フィールドエラーと入力された値と一緒にそのまま渡します。

`app/routes/report.tsx`:

```tsx
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>
  );
}
```

**SvelteKit**

既定のフォームアクションは、Ikiform のステータスとフィールドのエラーを付けて `fail()` を返します。JavaScript がなければページがそれを付けて再レンダリングされ、`use:enhance` があればその場で更新されます。`$app/env/private` から読めるよう、`src/env.ts` で `defineEnvVars` を使って `IKIFORM_SECRET_KEY` を宣言します。

`src/routes/report/+page.server.ts`:

```ts
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;
```

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

```svelte
<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**

サーバールートは `NUXT_IKIFORM_SECRET_KEY` から設定される `runtimeConfig.ikiformSecretKey` を使ってレポートを転送します。フィールドのエラーを `data` に入れて `createError` を投げ、`$fetch` がそれをクライアントに返します。

`server/api/report.post.ts`:

```ts
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 };
});
```

`app/components/ReportForm.vue`:

```vue
<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>
```

**React**

シングルページアプリはブラウザーから送信するので、ここにシークレットキーはありません。**Allowed domains**（許可ドメイン）がフォームを守ります。`Accept: application/json` を付けると、リダイレクトではなく JSON の応答が返ります。

`src/ReportForm.tsx`:

```tsx
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**

同じブラウザーからの送信を Vue コンポーネントで書いたものです。フォームには本物の `action` と `method` が残っているので、スクリプトの読み込みに失敗してもプレーンな方法で送信されます。

`src/components/ReportForm.vue`:

```vue
<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 は **Allowed domains** と人間確認をスキップします。その場合はすべての送信がサーバーの IP から来るため、訪問者ごとに 1 分あたり 5 件という Standard の制限が、全訪問者をまとめて数えることになります。サーバー経由で送信するフォームには Relaxed か Custom の[レート制限](https://ikiform.com/ja/docs/rate-limits.md)を選び、`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](https://ikiform.com/ja/docs/guides/nextjs.md)、[Astro](https://ikiform.com/ja/docs/guides/astro.md)、[SvelteKit](https://ikiform.com/ja/docs/guides/sveltekit.md)、[Nuxt](https://ikiform.com/ja/docs/guides/nuxt.md)、[React](https://ikiform.com/ja/docs/guides/react.md)、[Vue](https://ikiform.com/ja/docs/guides/vue.md)、[サーバーから](https://ikiform.com/ja/docs/guides/server.md)を参照してください。

## 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` で送信が拒否されます。両方の層で同じことを伝える方法は[バリデーション](https://ikiform.com/ja/guides/html-form-submission/validation.md)で説明しています。

## 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 番目の引数として渡します。

読み込み状態、フォーカス、ネットワークエラーまで含めた完全なパターンは[ページを再読み込みせずに送信](https://ikiform.com/ja/guides/html-form-submission/without-page-reload.md)にあります。エンドポイントが受け付けるすべてのヘッダーとボディ形式は、ドキュメントの [JavaScript と fetch](https://ikiform.com/ja/docs/frameworks.md) に載っています。

## 状況に合わせて選ぶ

以下の各ページは、送信の一部分を取り上げて上の概要より深く掘り下げ、そのケースのコードを示します。

- [ページを再読み込みせずに](https://ikiform.com/ja/guides/html-form-submission/without-page-reload.md): fetch と FormData でフォームを送り、訪問者をページにとどめる。フィールドのエラー、読み込み状態、リトライ、スクリプト失敗時のフォールバック付き。
- [送信後のリダイレクト](https://ikiform.com/ja/guides/html-form-submission/redirect-after-submit.md): フォーム送信後に訪問者を自分のサンクスページへ送る方法。303 リダイレクト、_redirect フィールド、許可されるドメイン、再読み込みの挙動を解説します。
- [action 属性](https://ikiform.com/ja/guides/html-form-submission/action-attribute.md): フォームの action 属性の役割、相対値や空の値の解決のされ方、formaction で 1 つのフォームを 2 か所に送る方法、静的ホストが 405 を返す理由を解説します。
- [フォームのバリデーション](https://ikiform.com/ja/guides/html-form-submission/validation.md): required、type、pattern、min でフォームを検証し、:user-invalid でエラーをスタイリングし、同じルールをフィールド定義でサーバー側にも適用します。

## 動作を確認する

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

1. フォームの **Allowed domains** に自分のサイトのドメインを追加し、開発中は `localhost` も追加します。空のままにすると、どこからの送信も受け付けます。
2. ブラウザーの開発者ツールで JavaScript をオフにしてフォームを送信します。それでも `303` でサンクスページに着くはずです。
3. JavaScript をオンにして Network パネルを開き、もう一度送信します。`/f/<formId>` へのリクエストは JSON 付きの `201` を返し、ページは再読み込みされないはずです。
4. 短すぎるメッセージで送信します。プレーンな送信ではフィールドのラベル付きで Ikiform のエラーページが表示され、スクリプト版ではフィールドの下にメッセージが表示されます。
5. サンクスページで再読み込みを押します。何も再送信されず、2 件目の送信データも現れないはずです。
6. ダッシュボードで **Submissions** を開きます。送ったすべてのフィールドを含む新しい送信データがあります。
7. ハニーポットを埋めて `_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` を追加してください。登録したドメインのサブドメインは許可されます。 |

## よくある質問

### HTML フォームの action 属性は何をするのですか？

フォームのデータを受け取る URL です。相対値はページのアドレスを基準に解決され、action のないフォームはそのページ自身に送信します。送信ボタンは formaction でこれを上書きできます。

### フォームは GET と POST のどちらを使うべきですか？

お問い合わせ、登録、注文フォームなど、個人データを送ったり状態を変えたりするものには POST を使います。GET は検索や絞り込みのフォーム向けで、フィールドが共有可能な URL に含まれるべき場合に使います。

### ページを再読み込みするとフォームが再送信されるのはなぜですか？

表示しているページが POST への直接の応答なので、ブラウザーがそれを繰り返すか尋ねるのです。成功した POST には、代わりにサンクスページへの 303 リダイレクトで応答しましょう。Ikiform はブラウザーからのすべての送信でそうしています。

### HTML フォームで JSON を送れますか？

フォーム単体では送れません。フォームが送るのは urlencoded か multipart のデータです。JSON を送るには、submit ハンドラーで FormData を使ってフォームを読み、JSON.stringify(Object.fromEntries(data)) を fetch で送信します。Ikiform は 3 つすべてを受け付けます。

### required 属性だけでフォームのバリデーションは十分ですか？

いいえ。人の助けにはなりますが、ボットは直接送信してそれを迂回します。サーバーでも検証してください。Ikiform ならフィールドを定義すれば、すべての送信が保存前にそれに照らしてチェックされます。

### バックエンドなしでフォームを送信できますか？

POST を受け取る何かは必要ですが、自分のサーバーである必要はありません。フォームの action を Ikiform のようなフォームバックエンドに向ければ、ページは完全に静的なままでかまいません。

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

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

## その他のガイド

- [ページを再読み込みせずに](https://ikiform.com/ja/guides/html-form-submission/without-page-reload.md): fetch と FormData でフォームを送り、訪問者をページにとどめる。フィールドのエラー、読み込み状態、リトライ、スクリプト失敗時のフォールバック付き。
- [送信後のリダイレクト](https://ikiform.com/ja/guides/html-form-submission/redirect-after-submit.md): フォーム送信後に訪問者を自分のサンクスページへ送る方法。303 リダイレクト、_redirect フィールド、許可されるドメイン、再読み込みの挙動を解説します。
- [action 属性](https://ikiform.com/ja/guides/html-form-submission/action-attribute.md): フォームの action 属性の役割、相対値や空の値の解決のされ方、formaction で 1 つのフォームを 2 か所に送る方法、静的ホストが 405 を返す理由を解説します。
- [フォームのバリデーション](https://ikiform.com/ja/guides/html-form-submission/validation.md): required、type、pattern、min でフォームを検証し、:user-invalid でエラーをスタイリングし、同じルールをフィールド定義でサーバー側にも適用します。
- [イベント登録](https://ikiform.com/ja/guides/event-registration.md): 自分のイベントページで出欠を受け付けます。フォーム、同伴者の上限、正確な人数集計、満員時の締め切り、受付リストまで、チケットアプリなしで。
- [Webhook の署名を検証する](https://ikiform.com/ja/guides/webhook-signature-verification.md): フォームの Webhook が本当に Ikiform から来たかを確認する方法。Standard Webhooks の HMAC-SHA256 方式、テストベクター、生のボディ、タイムスタンプ、シークレットのローテーションを解説します。
- [新しい SaaS のウェイトリスト](https://ikiform.com/ja/guides/waitlist.md): 自分のランディングページにローンチ前のウェイトリストを作ります。フォーム、スパム対策、重複、確認メール、ローンチ日のきれいなエクスポートまで扱います。
