# Cara kerja pengiriman formulir HTML

> Apa yang dilakukan browser saat formulir dikirim: field mana yang dikirim, bagaimana di-encode, apa yang seharusnya dijawab server, dan bagaimana Ikiform menjawab.

Terbit 2026-10-25 · https://ikiform.com/id/guides/html-form-submission

Saat seseorang menekan tombol kirim, browser memeriksa batasan formulir, mengumpulkan setiap kontrol yang punya nama dan aktif, meng-encode-nya dengan `enctype` formulir, dan mengirimnya ke URL `action` dengan `method` formulir. Server menjawab seperti memuat halaman biasa, dan server yang baik menjawab POST yang berhasil dengan redirect `303`, jadi refresh tidak mengirim formulir dua kali. Arahkan `action` ke backend formulir seperti Ikiform dan Anda mendapat redirect itu, validasi field, dan penyaringan spam tanpa server sendiri.

Formulir HTML lebih tua dari JavaScript dan masih menjadi cara paling andal untuk mengirim data dari halaman web. Formulir bekerja dengan script mati, di ponsel lambat, dan di setiap builder yang mengizinkan Anda menempel markup. Kebanyakan bug formulir berasal dari ketidaktahuan tentang apa yang dilakukan browser dengan sendirinya, jadi panduan ini membahas pengiriman langkah demi langkah, lalu menunjukkan di mana backend formulir berperan.

## Apa yang terjadi saat Anda menekan kirim

Pengiriman adalah navigasi biasa dengan body terlampir. Browser melakukan empat hal sebelum apa pun meninggalkan halaman, lalu menyerahkan request ke jaringan dan memuat apa pun yang kembali.

Diagram: Apa yang dilakukan browser antara klik kirim dan halaman berikutnya

1. Tombol submit, tombol Enter di field teks, atau form.requestSubmit() memulai pengiriman dan memicu event submit.
2. Kecuali formulir punya novalidate, browser memeriksa batasan setiap kontrol. Yang pertama tidak valid mendapat pesan dan tidak ada yang dikirim.
3. Browser menyusun form data set dari setiap kontrol yang punya nama dan tidak disabled.
4. Browser meng-encode set itu dengan enctype formulir, urlencoded kecuali Anda menentukan lain.
5. Browser bernavigasi ke URL action dengan method formulir, sambil mengirim body.
6. Server menjawab. 303 memberi tahu browser untuk memuat URL lain dengan GET, yang menjadi halaman tempat pengunjung berakhir.

Semua yang di atas garis terjadi tanpa jaringan sama sekali. Listener event submit bisa menghentikan alur di langkah pertama dan mengambil alih dengan fetch.

## Atribut yang menentukan request

Lima atribut di `<form>` menentukan ke mana data pergi dan bagaimana caranya. Masing-masing bisa ditimpa untuk satu tombol dengan atribut `form*` yang sesuai, begitulah satu formulir bisa punya tombol **Send** dan **Save draft** yang berperilaku berbeda.

| Atribut | Default | Fungsinya | Override di tombol |
| --- | --- | --- | --- |
| `action` | URL halaman itu sendiri | URL yang menerima data. Nilai relatif di-resolve terhadap halaman. Lihat [atribut action](https://ikiform.com/id/guides/html-form-submission/action-attribute.md) | `formaction` |
| `method` | `get` | `get` menaruh field di query string URL. `post` mengirimnya sebagai body. `dialog` menutup `<dialog>` tanpa mengirim apa pun | `formmethod` |
| `enctype` | `application/x-www-form-urlencoded` | Cara body `post` di-encode. `multipart/form-data` dibutuhkan untuk file. `text/plain` dimaksudkan untuk debugging | `formenctype` |
| `novalidate` | Mati | Melewati pemeriksaan batasan browser saat submit | `formnovalidate` |
| `target` | `_self` | Tempat respons dimuat. `_blank` membuka tab baru, iframe bernama membuat halaman tetap di tempat | `formtarget` |

Dua tombol, dua perilaku. Yang kedua melewati validasi, dan `name` serta `value` tombol yang diklik ditambahkan ke data, jadi server tahu mana yang ditekan:

`Dua tombol submit`:

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

### Kontrol mana yang dikirim

Browser hanya mengirim apa yang disebut spesifikasi HTML sebagai form data set. Kebanyakan kejutan di log kiriman berasal dari aturan ini:

- Kontrol butuh `name`. `id` saja tidak mengirim apa pun.
- Kontrol `disabled` dilewati. Gunakan `readonly` jika Anda ingin nilai ditampilkan, dikunci, dan dikirim.
- Checkbox atau radio yang tidak dicentang sama sekali tidak mengirim apa pun, bukan `false`. Checkbox tercentang tanpa `value` mengirim `on`.
- Hanya tombol yang mengirim formulir yang disertakan, dengan `name` dan `value`-nya.
- `<select multiple>` dan grup checkbox yang berbagi nama mengirim satu pasangan per nilai yang dipilih.
- Input di luar elemen `<form>` bisa bergabung dengan atribut `form` yang menyebut `id` formulir.

### Cara body di-encode

Dengan `enctype` default, body adalah satu baris pasangan `name=value` yang digabung dengan `&`, spasi menjadi `+`, dan semua karakter di luar set aman di-percent-encode. Ini request sungguhan untuk formulir laporan di bawah, seperti yang dikirim browser:

`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` membungkus setiap field dalam bagiannya sendiri dengan string boundary yang dipilih browser. Ukurannya lebih besar, tetapi bisa membawa file. Ikiform membaca keduanya. `text/plain` tidak punya cara andal untuk menandai akhir sebuah nilai, jadi Ikiform menolaknya dengan `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--
```

> Apa pun encoding-nya, pertahankan halaman dalam UTF-8. Ikiform men-decode setiap body sebagai UTF-8 dan menolak charset lain dengan `415 unsupported_charset`. Upload file belum didukung. Kiriman dengan file mendapat `422 files_not_supported`.

## Empat cara mengirim formulir

Post biasa hanyalah satu pilihan. Setiap pendekatan di bawah adalah pilihan yang wajar di suatu tempat; yang berubah adalah siapa yang menangani jawaban dan apa yang terjadi saat script mati.

| Pendekatan | Unggul dalam | Kekurangan |
| --- | --- | --- |
| `method="post"` biasa dengan redirect | Bekerja tanpa JavaScript, di builder mana pun. Browser menangani status loading, back, dan refresh | Muat ulang halaman penuh. Error field butuh halaman yang dirender server, atau halaman error dari backend |
| `fetch` dengan `FormData` | Tanpa reload. Anda yang menentukan tampilan sukses dan setiap error field | Anda yang menangani status loading, error jaringan, dan fokus. Tanpa kehati-hatian, ini rusak saat script mati. Lihat [tanpa reload halaman](https://ikiform.com/id/guides/html-form-submission/without-page-reload.md) |
| Form action framework, seperti server action Next.js atau form action SvelteKit | Server Anda melihat post lebih dulu, dan formulir yang sama bekerja sebelum dan sesudah hydration | Anda butuh server runtime, dan request ke backend berangkat dari server Anda, yang mengubah rate limit dan pemeriksaan origin |
| `method="get"` | Formulir pencarian dan filter. Hasilnya punya URL yang bisa dibagikan | Field masuk ke URL, riwayat, dan log server. Jangan pernah untuk data pribadi atau apa pun yang mengubah state |

Mulailah dengan post biasa lalu tambahkan `fetch` di atasnya. Dengan begitu formulir tetap bekerja saat script gagal dimuat, yang terjadi lebih sering daripada yang ditunjukkan kebanyakan analytics.

## Apa yang seharusnya dijawab server

Browser memperlakukan jawaban atas POST sebagai halaman baru. Jika halaman itu adalah respons itu sendiri, dengan status `200`, browser mengingat bahwa halaman itu berasal dari POST. Tekan refresh dan browser menawarkan untuk mengirim formulir lagi; tekan back lalu forward dan beberapa browser melakukan hal yang sama. Begitulah orang membayar dua kali dan tim support menerima pesan yang sama tiga kali.

Solusinya adalah pola Post/Redirect/Get. Server menangani POST, lalu menjawab `303 See Other` dengan header `Location`. Browser memuat URL itu dengan GET, dan halaman yang dilihat pengunjung berasal dari GET, jadi refresh tidak berbahaya.

Diagram: Menjawab POST dengan halaman dibandingkan dengan menjawabnya dengan redirect 303

1. Dengan jawaban 200, browser mengirim formulir dan menampilkan body respons sebagai halaman.
2. Halaman itu terikat pada POST, jadi refresh meminta kirim ulang dan konfirmasi mengirim data lagi.
3. Dengan jawaban 303, browser mengirim formulir dan diminta memuat URL lain.
4. Browser memuat URL itu dengan GET. Refresh hanya mengulang GET, jadi tidak ada yang terkirim dua kali.

Ikiform selalu menjawab post dari browser dengan 303, jadi formulir Anda mendapat perilaku ini secara gratis.

| Status | Apa yang dilakukan browser berikutnya | Gunakan untuk |
| --- | --- | --- |
| `303 See Other` | GET ke `Location`, body dibuang | Setelah form post apa pun yang berhasil |
| `302 Found` | Dalam praktiknya browser beralih ke GET, tetapi spesifikasi mengizinkan method tetap | Server lama. Lebih baik `303` |
| `307` dan `308` | Mengulang POST yang sama dengan body yang sama di URL baru | Memindahkan endpoint, bukan halaman terima kasih |
| `200` dengan HTML | Tampilkan halaman; refresh menawarkan kirim ulang | Menampilkan ulang formulir dengan error |
| `4xx` dengan HTML | Tampilkan halaman error | Error yang bisa diperbaiki pengunjung dengan kembali |

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

## Cara Ikiform menjawab

Satu endpoint, `https://ikiform.com/f/<formId>`, menjawab browser maupun kode. Endpoint memutuskan per request apakah menjawab dengan halaman atau JSON, dari header yang diatur browser dengan sendirinya:

| Request | Jawaban |
| --- | --- |
| `Accept` mencakup `application/json` | JSON |
| Body berupa JSON | JSON |
| `Sec-Fetch-Mode: navigate`, yaitu setiap form post biasa | Redirect atau halaman hosted |
| `Sec-Fetch-Mode` lainnya, seperti `cors` dari `fetch` | JSON |
| Tanpa `Sec-Fetch-Mode`, dan `Accept` mencakup `text/html` | Redirect atau halaman hosted |
| Lainnya, seperti curl | JSON |

`Sec-Fetch-Mode` adalah header yang diatur browser dan tidak bisa diubah script halaman. Form post yang menavigasi tab mengirim `navigate`; panggilan `fetch` mengirim `cors` untuk request lintas situs. Jadi formulir yang sama mendapat redirect saat dikirim dengan cara biasa dan JSON saat script Anda yang mengirimnya, tanpa sakelar apa pun di sisi Anda. Tabel lengkapnya ada di [JSON atau redirect](https://ikiform.com/id/docs/how-it-works.md#response-modes).

Diagram: Endpoint Ikiform yang sama menjawab form post biasa dan panggilan fetch

1. Form post biasa menavigasi tab, jadi browser mengirim Sec-Fetch-Mode: navigate.
2. Ikiform menjawab dalam mode halaman: redirect 303 saat sukses, atau halaman error hosted dengan status asli error tersebut.
3. Panggilan fetch mengirim Sec-Fetch-Mode: cors, atau kode Anda meminta JSON dengan header Accept.
4. Ikiform menjawab dalam mode JSON: 201 dengan id kiriman, atau envelope error dengan error field yang dikunci per id field.

Anda tidak mengonfigurasi mode. Mode mengikuti cara request dibuat.

### Ke mana post yang berhasil mendarat

Post browser yang berhasil mendapat `303`. Ikiform memilih tujuan dengan urutan ini:

1. Pengaturan **Redirect after submit** formulir di dashboard. URL `http:` atau `https:` apa pun bisa, karena hanya workspace Anda yang bisa mengaturnya.
2. Field `_redirect` di post, atau `_next` jika tidak ada `_redirect`, saat berupa URL `https:` tanpa username, password, atau port kustom, di salah satu **Allowed domains** formulir atau subdomainnya.
3. Jika tidak, halaman terima kasih hosted di `/f/<formId>/thanks`. Jika post datang dari halaman `https:` di domain yang diizinkan, halaman itu menautkan kembali ke sana.

`_redirect` yang melanggar aturan diabaikan, tidak pernah menjadi error, dan formulir tanpa allowed domains mengabaikan `_redirect` sepenuhnya. Itu mencegah siapa pun memakai formulir Anda untuk melempar pengunjung ke situs pilihan mereka. [Redirect setelah submit](https://ikiform.com/id/guides/html-form-submission/redirect-after-submit.md) membahas halaman terima kasih secara mendalam, dan aturan lengkapnya ada di [formulir HTML](https://ikiform.com/id/docs/html.md#redirect-rules).

### Saat post ditolak

Dalam mode halaman, post yang ditolak menampilkan halaman error hosted dengan status HTTP asli error tersebut. Untuk error validasi, halaman itu mencantumkan setiap masalah di bawah label field Anda dan menautkan ke halaman asal post. Tombol **Back** browser biasanya kembali ke formulir dengan isian pengunjung masih ada; tautannya memuat halaman dari awal. Dalam mode JSON, error yang sama kembali sebagai satu envelope. Setiap kode ada di [Errors](https://ikiform.com/id/docs/errors.md).

`422 dalam mode JSON`:

```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"
}
```

Spam adalah satu-satunya hal yang tidak pernah terlihat seperti error. Post yang dinilai spam mendapat `303` atau `201` yang sama seperti yang asli, jadi bot tidak belajar apa pun, dan tidak ada email atau webhook yang dikirim.

## Formulir di stack Anda

Berikut formulir sungguhan, laporan halaman rusak, di setiap stack. Pilih milik Anda; pilihan itu berlaku untuk setiap contoh kode di panduan ini. Definisi field di bawah sama dengan yang ditegakkan Ikiform di server, jadi cobalah: buat detailnya pendek atau ketik `example` sebagai alamat dan Anda mendapat `422` yang akan didapat formulir Anda.

Formulir: Report a broken page

- **Email Anda** (`email`, email, wajib)
- **Alamat halaman** (`page`, url, wajib)
- **Seberapa parah** (`severity`, select, wajib)
- **Apa yang terjadi** (`details`, textarea, wajib)

Tempel definisi ini di tab **Fields** formulir di dashboard dan Ikiform memvalidasi setiap post terhadapnya. Lihat [Field dan validasi](https://ikiform.com/id/docs/fields.md).

**HTML**

Tanpa script sama sekali. Browser memeriksa `required`, `type`, dan `minlength`, mengirim field sebagai teks urlencoded, mengikuti `303` dari Ikiform ke halaman `_redirect` Anda, dan menampilkan halaman error Ikiform jika server menolak post.

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

Server action menerima `FormData`, meneruskannya sebagai JSON dengan secret key Anda, dan mengembalikan error field dari Ikiform. `useActionState` menaruh setiap pesan di bawah input-nya dan mempertahankan isian pengunjung.

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

Dengan `accept: "form"`, Zod menolak email atau URL yang salah sebelum apa pun meninggalkan server Anda, dan `isInputError` mengekspos pesan itu per field. Error field `422` milik Ikiform kembali sebagai data action dengan bentuk yang sama.

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

Server function POST membaca `FormData` di `.validator()` dan mengembalikan error field sebagai data biasa, jadi komponen bisa merendernya tanpa menangkap apa pun.

`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` route adalah tujuan sebenarnya formulir, jadi `<Form>` juga bekerja sebelum hydration. `data()` meneruskan status `422` dari Ikiform bersama error field dan nilai yang diketik.

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

Form action default mengembalikan `fail()` dengan status dan error field dari Ikiform. Tanpa JavaScript halaman dirender ulang dengannya; dengan `use:enhance` halaman diperbarui di tempat. Deklarasikan `IKIFORM_SECRET_KEY` di `src/env.ts` dengan `defineEnvVars` agar `$app/env/private` bisa membacanya.

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

Server route meneruskan laporan dengan `runtimeConfig.ikiformSecretKey`, yang diatur dari `NUXT_IKIFORM_SECRET_KEY`. Route melempar `createError` dengan error field di `data`, yang dikembalikan `$fetch` di klien.

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

Single-page app mengirim dari browser, jadi tidak ada secret key di sini; **Allowed domains** melindungi formulir. `Accept: application/json` mendapat jawaban JSON alih-alih redirect.

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

Post browser yang sama di komponen Vue. Formulir mempertahankan `action` dan `method` sungguhan, jadi tetap terkirim dengan cara biasa jika script gagal dimuat.

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

> Versi sisi server mengirim dengan secret key, sehingga Ikiform melewati **Allowed domains** dan pemeriksaan manusianya. Semua kiriman lalu datang dari IP server Anda, dan batas Standard 5 per menit per pengunjung menghitung semua pengunjung Anda sebagai satu. Pilih [rate limit](https://ikiform.com/id/docs/rate-limits.md) Relaxed atau Custom untuk formulir yang Anda kirim lewat server, dan simpan `IKIFORM_SECRET_KEY` di environment variable khusus server.

Setiap versi server meneruskan header `Origin` dari browser jika ada, karena `fetch` sisi server tidak mengirimnya dan Ikiform memakainya untuk mengenali uji gratis dari `localhost` di workspace tanpa paket.

- Setiap versi mempertahankan `method` sungguhan dan, jika mengirim langsung ke Ikiform, `action` sungguhan. Dengan script mati formulir tetap terkirim dan mendapat `303`.
- `name` setiap input adalah `id` field dalam definisi. Ketidakcocokan, seperti `url` alih-alih `page`, mendapat `422` dengan `Unknown field "url"`.
- Input `_gotcha` tersembunyi adalah honeypot. Bot yang mengisinya disimpan sebagai spam, diam-diam.
- Versi JavaScript menampilkan `error.fields` di bawah setiap input, memakai pesan pertama untuk setiap field. Versi HTML biasa menyerahkannya ke halaman error Ikiform.

Dokumentasi membahas lebih dalam per framework: [Next.js](https://ikiform.com/id/docs/guides/nextjs.md), [Astro](https://ikiform.com/id/docs/guides/astro.md), [SvelteKit](https://ikiform.com/id/docs/guides/sveltekit.md), [Nuxt](https://ikiform.com/id/docs/guides/nuxt.md), [React](https://ikiform.com/id/docs/guides/react.md), [Vue](https://ikiform.com/id/docs/guides/vue.md), dan [dari server](https://ikiform.com/id/docs/guides/server.md).

## Dua lapis validasi

Validasi browser dan validasi server punya tugas berbeda. Pemeriksaan batasan browser (`required`, `type="email"`, `minlength`, `pattern`) memberi umpan balik instan, tetapi siapa pun bisa melewatinya: bot mengirim langsung, dan script bisa memanggil `form.submit()`, yang tidak pernah menjalankan pemeriksaan. Server adalah satu-satunya tempat aturan benar-benar berlaku.

| Aturan | Di browser | Di definisi field Ikiform |
| --- | --- | --- |
| Wajib | `required` | `"required": true` |
| Email | `type="email"` | `"type": "email"`, dengan `allowed_domains` opsional |
| URL | `type="url"` | `"type": "url"`, dengan `https_only` opsional |
| Panjang | `minlength`, `maxlength` | `validation.min_length`, `max_length` |
| Hanya pilihan yang tercantum | `<select>`, yang diabaikan bot | `options`; yang lain ditolak |
| Tanpa field tambahan | Tidak mungkin | Field apa pun yang tidak ada di definisi ditolak |

Tanpa definisi, Ikiform menerima field apa pun dan menyimpannya apa adanya. Publikasikan definisi dan Ikiform memvalidasi, menyimpan angka dan checkbox sebagai angka dan boolean sungguhan, serta menolak post dengan `422 validation_failed` sebelum apa pun disimpan. [Validasi](https://ikiform.com/id/guides/html-form-submission/validation.md) menunjukkan cara menjaga kedua lapis tetap konsisten.

## Mengirim dengan JavaScript

Agar pengunjung tetap di halaman, dengarkan `submit`, panggil `preventDefault()`, dan kirim `new FormData(form)` dengan `fetch`. Beberapa detail menentukan apakah itu langsung berhasil:

- Jangan atur `Content-Type` sendiri saat body berupa `FormData`. Browser mengatur `multipart/form-data` beserta boundary-nya; header yang ditulis manual tidak punya boundary dan body tidak bisa di-parse.
- Kirim `Accept: application/json`. Ikiform toh akan memilih JSON dari `Sec-Fetch-Mode`, tetapi header ini membuatnya eksplisit, dan termasuk header request yang bisa dikirim browser lintas situs tanpa preflight.
- Body `FormData` atau `URLSearchParams` adalah request lintas situs sederhana, jadi tidak ada preflight. Body JSON dengan `Content-Type: application/json` memicu preflight lebih dulu; Ikiform menjawabnya, tetapi itu menambah satu perjalanan pulang-pergi.
- `form.submit()` melewati validasi dan event `submit`. `form.requestSubmit()` menjalankan keduanya, seperti klik.
- Berikan tombol yang diklik sebagai argumen kedua, `new FormData(form, event.submitter)`, jika `name` dan `value`-nya penting.

Pola lengkapnya, dengan status loading, fokus, dan error jaringan, ada di [tanpa reload halaman](https://ikiform.com/id/guides/html-form-submission/without-page-reload.md). Dokumentasi [JavaScript dan fetch](https://ikiform.com/id/docs/frameworks.md) mencantumkan setiap header dan format body yang diterima endpoint.

## Pilih situasi Anda

Setiap halaman ini mengambil satu bagian dari pengiriman dan membahasnya lebih jauh dari ringkasan di atas, dengan kode untuk kasus itu.

- [Tanpa reload halaman](https://ikiform.com/id/guides/html-form-submission/without-page-reload.md): Kirim formulir dengan fetch dan FormData agar pengunjung tetap di halaman, dengan error field, status loading, percobaan ulang, dan cadangan saat script gagal.
- [Redirect setelah submit](https://ikiform.com/id/guides/html-form-submission/redirect-after-submit.md): Arahkan pengunjung ke halaman terima kasih Anda sendiri setelah formulir terkirim: redirect 303, field _redirect, domain mana yang diizinkan, dan apa efek refresh.
- [Atribut action](https://ikiform.com/id/guides/html-form-submission/action-attribute.md): Apa fungsi atribut action formulir, bagaimana nilai relatif dan kosong di-resolve, bagaimana formaction mengirim satu formulir ke dua tempat, dan mengapa host statis menjawab 405.
- [Validasi formulir](https://ikiform.com/id/guides/html-form-submission/validation.md): Validasi formulir dengan required, type, pattern, dan min, beri gaya error dengan :user-invalid, dan terapkan aturan yang sama di server dengan definisi field.

## Pastikan berfungsi

Jalankan ini sekali per formulir, di jendela privat. Ikiform tidak punya paket gratis. Pengujian gratis: workspace tanpa paket menerima 50 kiriman uji per bulan dari `localhost`. Untuk live, pilih Pro mulai $6/bln untuk 500 kiriman, atau bayar sekali: 10.000 kiriman seharga $49 atau 50.000 kiriman seharga $149, dengan kredit yang tidak pernah kedaluwarsa. Lihat [harga](https://ikiform.com/id/pricing).

1. Di **Allowed domains** formulir, tambahkan domain situs Anda, ditambah `localhost` selama pengembangan. Biarkan kosong untuk menerima kiriman dari mana saja.
2. Matikan JavaScript di developer tools browser Anda dan kirim formulir. Formulir tetap harus mendarat di halaman terima kasih Anda dengan `303`.
3. Dengan JavaScript aktif, buka panel Network dan kirim lagi. Request ke `/f/<formId>` seharusnya menjawab `201` dengan JSON, dan halaman tidak dimuat ulang.
4. Kirim dengan pesan yang terlalu pendek. Post biasa menampilkan halaman error Ikiform dengan label field Anda; versi script menampilkan pesan di bawah field.
5. Di halaman terima kasih, tekan refresh. Tidak boleh ada yang terkirim ulang, dan tidak boleh muncul kiriman kedua.
6. Buka **Submissions** di dashboard. Kiriman baru ada di sana dengan setiap field yang Anda kirim.
7. Kirim dengan honeypot terisi, `_gotcha=bot`. Kiriman tetap mendapat jawaban sukses, tetapi disimpan sebagai spam dan tidak memicu email atau webhook.

## Pemecahan masalah

| Masalah | Yang harus dilakukan |
| --- | --- |
| `405 Method Not Allowed` setelah submit | Formulir mengirim ke host statis Anda sendiri, yang tidak bisa menerima POST. Periksa `action`: harus berupa URL lengkap `https://ikiform.com/f/<formId>`, bukan path relatif atau kosong. |
| Membuka endpoint di tab menampilkan `404 not_found` | Itu wajar. Tab mengirim GET, dan endpoint hanya menerima POST. Pastikan `method` formulir adalah `post`; formulir tanpa method mengirim GET dengan field di URL. |
| `415 unsupported_media_type` | Formulir punya `enctype="text/plain"`, atau script mengirim tipe yang tidak dibaca Ikiform. Hapus `enctype`, atau gunakan `multipart/form-data`. |
| `422 validation_failed` | Kiriman tidak cocok dengan definisi field formulir. `error.fields` menyebutkan setiap field dan alasannya. Samakan `name` input dengan ID field. |
| Browser mendarat di halaman terima kasih Ikiform, bukan halaman Anda | `_redirect` diabaikan kecuali domainnya ada di **Allowed domains** dan berupa URL `https:`. Tambahkan domainnya, atau atur **Redirect after submit** di pengaturan formulir sebagai gantinya. |
| Sebuah field hilang dari kiriman | Field itu tidak punya `name`, berstatus `disabled`, atau berupa checkbox yang tidak dicentang. Beri `name`, gunakan `readonly` alih-alih `disabled`, atau definisikan field `checkbox` agar kotak yang tidak dicentang disimpan sebagai `false`. |
| Refresh meminta kirim ulang formulir | Halaman menjawab POST dengan `200`. Post langsung ke Ikiform selalu mendapat `303`; jika server Anda sendiri berada di antaranya, lakukan redirect setelah menangani post. |
| `403 origin_not_allowed`, atau setiap kiriman masuk ke spam | Domain halaman tidak ada di **Allowed domains** formulir. Tambahkan, ditambah `localhost` untuk uji lokal. Subdomain dari domain yang terdaftar diizinkan. |

## Pertanyaan

### Apa fungsi atribut action di formulir HTML?

Itu URL yang menerima data formulir. Nilai relatif di-resolve terhadap alamat halaman, dan formulir tanpa action mengirim ke halaman itu sendiri. Tombol submit bisa menimpanya dengan formaction.

### Haruskah formulir memakai GET atau POST?

POST untuk apa pun yang mengirim data pribadi atau mengubah state, seperti formulir kontak, pendaftaran, atau pesanan. GET untuk formulir pencarian dan filter, di mana field memang seharusnya ada di URL yang bisa dibagikan.

### Mengapa me-refresh halaman mengirim formulir saya lagi?

Halaman yang Anda lihat adalah respons langsung dari POST, jadi browser menawarkan untuk mengulangnya. Jawab POST yang berhasil dengan redirect 303 ke halaman terima kasih. Ikiform melakukan ini untuk setiap post dari browser.

### Bisakah formulir HTML mengirim JSON?

Tidak dengan sendirinya. Formulir mengirim data urlencoded atau multipart. Untuk mengirim JSON, baca formulir dengan FormData di handler submit dan kirim JSON.stringify(Object.fromEntries(data)) dengan fetch. Ikiform menerima ketiganya.

### Apakah atribut required cukup untuk memvalidasi formulir?

Tidak. Atribut itu membantu manusia, tetapi bot mengirim langsung dan melewatinya. Validasi juga di server. Dengan Ikiform, definisikan field dan setiap post diperiksa terhadapnya sebelum disimpan.

### Bisakah saya mengirim formulir tanpa backend?

Anda butuh sesuatu untuk menerima POST, tetapi tidak harus server Anda. Arahkan action formulir ke backend formulir seperti Ikiform, dan halaman bisa tetap sepenuhnya statis.

### Bisakah formulir menerima upload file?

Belum. Kiriman yang berisi file ditolak dengan 422 files_not_supported. Minta link ke file sebagai gantinya, seperti URL shared drive.

## Panduan lainnya

- [Tanpa reload halaman](https://ikiform.com/id/guides/html-form-submission/without-page-reload.md): Kirim formulir dengan fetch dan FormData agar pengunjung tetap di halaman, dengan error field, status loading, percobaan ulang, dan cadangan saat script gagal.
- [Redirect setelah submit](https://ikiform.com/id/guides/html-form-submission/redirect-after-submit.md): Arahkan pengunjung ke halaman terima kasih Anda sendiri setelah formulir terkirim: redirect 303, field _redirect, domain mana yang diizinkan, dan apa efek refresh.
- [Atribut action](https://ikiform.com/id/guides/html-form-submission/action-attribute.md): Apa fungsi atribut action formulir, bagaimana nilai relatif dan kosong di-resolve, bagaimana formaction mengirim satu formulir ke dua tempat, dan mengapa host statis menjawab 405.
- [Validasi formulir](https://ikiform.com/id/guides/html-form-submission/validation.md): Validasi formulir dengan required, type, pattern, dan min, beri gaya error dengan :user-invalid, dan terapkan aturan yang sama di server dengan definisi field.
- [Pendaftaran acara](https://ikiform.com/id/guides/event-registration.md): Terima RSVP di halaman acara Anda sendiri: formulir, batas tamu, jumlah peserta yang jujur, menutup saat penuh, dan daftar check-in, tanpa aplikasi tiket.
- [Verifikasi tanda tangan webhook](https://ikiform.com/id/guides/webhook-signature-verification.md): Pastikan webhook formulir benar-benar berasal dari Ikiform: skema HMAC-SHA256 Standard Webhooks, test vector, body mentah, timestamp, dan rotasi secret.
- [Daftar tunggu untuk SaaS baru](https://ikiform.com/id/guides/waitlist.md): Bangun daftar tunggu pra-peluncuran di landing page Anda sendiri: formulir, perlindungan spam, duplikat, email konfirmasi, dan ekspor rapi di hari peluncuran.
