Panduan14 menit baca
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.
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.
- Tombol submit, tombol Enter di field teks, atau form.requestSubmit() memulai pengiriman dan memicu event submit.
- Kecuali formulir punya novalidate, browser memeriksa batasan setiap kontrol. Yang pertama tidak valid mendapat pesan dan tidak ada yang dikirim.
- Browser menyusun form data set dari setiap kontrol yang punya nama dan tidak disabled.
- Browser meng-encode set itu dengan enctype formulir, urlencoded kecuali Anda menentukan lain.
- Browser bernavigasi ke URL action dengan method formulir, sambil mengirim body.
- Server menjawab. 303 memberi tahu browser untuk memuat URL lain dengan GET, yang menjadi halaman tempat pengunjung berakhir.
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 | 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:
<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.idsaja tidak mengirim apa pun. - Kontrol
disableddilewati. Gunakanreadonlyjika Anda ingin nilai ditampilkan, dikunci, dan dikirim. - Checkbox atau radio yang tidak dicentang sama sekali tidak mengirim apa pun, bukan
false. Checkbox tercentang tanpavaluemengirimon. - Hanya tombol yang mengirim formulir yang disertakan, dengan
namedanvalue-nya. <select multiple>dan grup checkbox yang berbagi nama mengirim satu pasangan per nilai yang dipilih.- Input di luar elemen
<form>bisa bergabung dengan atributformyang menyebutidformulir.
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:
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.
POST /f/frm_xxxxxxxxxxxxxxxx HTTP/1.1
Host: ikiform.com
Content-Type: multipart/form-data; boundary=----formdata7f3a
------formdata7f3a
Content-Disposition: form-data; name="email"
ada@example.com
------formdata7f3a
Content-Disposition: form-data; name="severity"
broken
------formdata7f3a--415 unsupported_charset. 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 |
| 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.
- Dengan jawaban 200, browser mengirim formulir dan menampilkan body respons sebagai halaman.
- Halaman itu terikat pada POST, jadi refresh meminta kirim ulang dan konfirmasi mengirim data lagi.
- Dengan jawaban 303, browser mengirim formulir dan diminta memuat URL lain.
- Browser memuat URL itu dengan GET. Refresh hanya mengulang GET, jadi tidak ada yang terkirim dua kali.
| 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 |
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.comCara 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.
- Form post biasa menavigasi tab, jadi browser mengirim Sec-Fetch-Mode: navigate.
- Ikiform menjawab dalam mode halaman: redirect 303 saat sukses, atau halaman error hosted dengan status asli error tersebut.
- Panggilan fetch mengirim Sec-Fetch-Mode: cors, atau kode Anda meminta JSON dengan header Accept.
- Ikiform menjawab dalam mode JSON: 201 dengan id kiriman, atau envelope error dengan error field yang dikunci per id field.
Ke mana post yang berhasil mendarat
Post browser yang berhasil mendapat 303. Ikiform memilih tujuan dengan urutan ini:
- Pengaturan Redirect after submit formulir di dashboard. URL
http:atauhttps:apa pun bisa, karena hanya workspace Anda yang bisa mengaturnya. - Field
_redirectdi post, atau_nextjika tidak ada_redirect, saat berupa URLhttps:tanpa username, password, atau port kustom, di salah satu Allowed domains formulir atau subdomainnya. - Jika tidak, halaman terima kasih hosted di
/f/<formId>/thanks. Jika post datang dari halamanhttps: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 membahas halaman terima kasih secara mendalam, dan aturan lengkapnya ada di formulir HTML.
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.
{
"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.
Pratinjau
Menjalankan validator Ikiform di browser Anda. Tidak ada yang dikirim.
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.
<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>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.
"use server";
import { headers } from "next/headers";
export interface ReportState {
sent: boolean;
fields: Record<string, string[]>;
message: string;
values: Record<string, string>;
}
const NAMES = ["email", "page", "severity", "details"] as const;
export async function sendReport(_previous: ReportState, formData: FormData): Promise<ReportState> {
const values = Object.fromEntries(NAMES.map((name) => [name, String(formData.get(name) ?? "").trim()]));
const origin = (await headers()).get("origin");
const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.IKIFORM_SECRET_KEY}`,
...(origin ? { Origin: origin } : {}),
},
body: JSON.stringify({ ...values, _gotcha: String(formData.get("_gotcha") ?? "") }),
});
const result = await response.json().catch(() => null);
if (result?.ok) return { sent: true, fields: {}, message: "Thanks. We'll look at that page today.", values: {} };
return {
sent: false,
fields: result?.error?.fields ?? {},
message: result?.error?.message ?? "The report didn't go through. Try again.",
values,
};
}"use client";
import { useActionState } from "react";
import { type ReportState, sendReport } from "./actions";
const initial: ReportState = { sent: false, fields: {}, message: "", values: {} };
function FieldError({ id, messages }: { id: string; messages?: string[] }) {
return messages?.length ? <p id={`${id}-error`}>{messages[0]}</p> : null;
}
export function ReportForm() {
const [state, action, pending] = useActionState(sendReport, initial);
if (state.sent) return <p role="status">{state.message}</p>;
return (
<form action={action}>
<label htmlFor="email">Your email</label>
<input id="email" name="email" type="email" required defaultValue={state.values.email}
aria-invalid={Boolean(state.fields.email)} aria-describedby="email-error" />
<FieldError id="email" messages={state.fields.email} />
<label htmlFor="page">Page address</label>
<input id="page" name="page" type="url" required defaultValue={state.values.page}
aria-invalid={Boolean(state.fields.page)} aria-describedby="page-error" />
<FieldError id="page" messages={state.fields.page} />
<label htmlFor="severity">How bad is it</label>
<select id="severity" name="severity" defaultValue={state.values.severity ?? "broken"}>
<option value="cosmetic">Looks wrong</option>
<option value="broken">Something doesn't work</option>
<option value="blocking">I can't continue</option>
</select>
<label htmlFor="details">What happened</label>
<textarea id="details" name="details" rows={5} required minLength={20} defaultValue={state.values.details}
aria-invalid={Boolean(state.fields.details)} aria-describedby="details-error" />
<FieldError id="details" messages={state.fields.details} />
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{state.message ? <p role="alert">{state.message}</p> : null}
<button disabled={pending}>{pending ? "Sending" : "Send report"}</button>
</form>
);
}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.
import { defineAction } from "astro:actions";
import { getSecret } from "astro:env/server";
import { z } from "astro/zod";
export const server = {
reportPage: defineAction({
accept: "form",
input: z.object({
email: z.email(),
page: z.url(),
severity: z.enum(["cosmetic", "broken", "blocking"]),
details: z.string().min(20, "Tell us a little more, at least 20 characters."),
_gotcha: z.string().nullish(),
}),
handler: async (input, context) => {
const origin = context.request.headers.get("origin");
const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${getSecret("IKIFORM_SECRET_KEY")}`,
...(origin ? { Origin: origin } : {}),
},
body: JSON.stringify(input),
});
const result = await response.json();
if (result.ok) return { sent: true, fields: {} as Record<string, string[]> };
return { sent: false, fields: (result.error.fields ?? {}) as Record<string, string[]> };
},
}),
};---
import { actions, isInputError } from "astro:actions";
export const prerender = false;
const result = Astro.getActionResult(actions.reportPage);
const fields = result?.error && isInputError(result.error) ? result.error.fields : (result?.data?.fields ?? {});
---
{result?.data?.sent ? (
<p role="status">Thanks. We'll look at that page today.</p>
) : (
<form method="POST" action={actions.reportPage}>
<label for="email">Your email</label>
<input id="email" name="email" type="email" required />
{fields.email && <p>{fields.email[0]}</p>}
<label for="page">Page address</label>
<input id="page" name="page" type="url" required />
{fields.page && <p>{fields.page[0]}</p>}
<label for="severity">How bad is it</label>
<select id="severity" name="severity">
<option value="cosmetic">Looks wrong</option>
<option value="broken" selected>Something doesn't work</option>
<option value="blocking">I can't continue</option>
</select>
<label for="details">What happened</label>
<textarea id="details" name="details" rows="5" minlength="20" required></textarea>
{fields.details && <p>{fields.details[0]}</p>}
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
<button>Send report</button>
</form>
)}Server function POST membaca FormData di .validator() dan mengembalikan error field sebagai data biasa, jadi komponen bisa merendernya tanpa menangkap apa pun.
import { createFileRoute } from "@tanstack/react-router";
import { createServerFn, useServerFn } from "@tanstack/react-start";
import { getRequestHeader } from "@tanstack/react-start/server";
import { type FormEvent, useState } from "react";
type Outcome = { sent: boolean; fields: Record<string, string[]>; message: string };
const sendReport = createServerFn({ method: "POST" })
.validator((data: unknown) => {
if (!(data instanceof FormData)) throw new Error("Expected form data");
const read = (name: string) => String(data.get(name) ?? "").trim();
return {
email: read("email"),
page: read("page"),
severity: read("severity"),
details: read("details"),
_gotcha: read("_gotcha"),
};
})
.handler(async ({ data }): Promise<Outcome> => {
const origin = getRequestHeader("origin");
const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.IKIFORM_SECRET_KEY}`,
...(origin ? { Origin: origin } : {}),
},
body: JSON.stringify(data),
});
const result = await response.json();
if (result.ok) return { sent: true, fields: {}, message: "Thanks. We'll look at that page today." };
return { sent: false, fields: result.error.fields ?? {}, message: String(result.error.message) };
});
export const Route = createFileRoute("/report")({ component: Report });
function Report() {
const send = useServerFn(sendReport);
const [outcome, setOutcome] = useState<Outcome | null>(null);
const [pending, setPending] = useState(false);
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setPending(true);
try {
setOutcome(await send({ data: new FormData(event.currentTarget) }));
} catch {
setOutcome({ sent: false, fields: {}, message: "The report didn't go through. Try again." });
} finally {
setPending(false);
}
}
if (outcome?.sent) return <p role="status">{outcome.message}</p>;
const fields = outcome?.fields ?? {};
return (
<form method="post" onSubmit={onSubmit}>
<label htmlFor="email">Your email</label>
<input id="email" name="email" type="email" required aria-invalid={Boolean(fields.email)} />
{fields.email ? <p>{fields.email[0]}</p> : null}
<label htmlFor="page">Page address</label>
<input id="page" name="page" type="url" required aria-invalid={Boolean(fields.page)} />
{fields.page ? <p>{fields.page[0]}</p> : null}
<label htmlFor="severity">How bad is it</label>
<select id="severity" name="severity" defaultValue="broken">
<option value="cosmetic">Looks wrong</option>
<option value="broken">Something doesn't work</option>
<option value="blocking">I can't continue</option>
</select>
<label htmlFor="details">What happened</label>
<textarea id="details" name="details" rows={5} minLength={20} required aria-invalid={Boolean(fields.details)} />
{fields.details ? <p>{fields.details[0]}</p> : null}
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{outcome?.message ? <p role="alert">{outcome.message}</p> : null}
<button disabled={pending}>{pending ? "Sending" : "Send report"}</button>
</form>
);
}action route adalah tujuan sebenarnya formulir, jadi <Form> juga bekerja sebelum hydration. data() meneruskan status 422 dari Ikiform bersama error field dan nilai yang diketik.
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>
);
}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.
import { fail } from "@sveltejs/kit";
import { IKIFORM_SECRET_KEY } from "$app/env/private";
import type { Actions } from "./$types";
export const actions = {
default: async ({ request, fetch, url }) => {
const form = await request.formData();
const values = {
email: String(form.get("email") ?? "").trim(),
page: String(form.get("page") ?? "").trim(),
severity: String(form.get("severity") ?? ""),
details: String(form.get("details") ?? "").trim(),
};
const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${IKIFORM_SECRET_KEY}`,
Origin: request.headers.get("origin") ?? url.origin,
},
body: JSON.stringify({ ...values, _gotcha: String(form.get("_gotcha") ?? "") }),
});
const result = await response.json();
if (!result.ok) {
return fail(response.status, { values, fields: (result.error.fields ?? {}) as Record<string, string[]> });
}
return { sent: true };
},
} satisfies Actions;<script lang="ts">
import { enhance } from "$app/forms";
import type { PageProps } from "./$types";
let { form }: PageProps = $props();
let fields = $derived(form?.fields ?? {});
const SEVERITIES = [
["cosmetic", "Looks wrong"],
["broken", "Something doesn't work"],
["blocking", "I can't continue"],
];
</script>
{#if form?.sent}
<p role="status">Thanks. We'll look at that page today.</p>
{:else}
<form method="POST" use:enhance>
<label for="email">Your email</label>
<input id="email" name="email" type="email" required value={form?.values?.email ?? ""} />
{#if fields.email}<p>{fields.email[0]}</p>{/if}
<label for="page">Page address</label>
<input id="page" name="page" type="url" required value={form?.values?.page ?? ""} />
{#if fields.page}<p>{fields.page[0]}</p>{/if}
<label for="severity">How bad is it</label>
<select id="severity" name="severity">
{#each SEVERITIES as [value, label]}
<option {value} selected={(form?.values?.severity ?? "broken") === value}>{label}</option>
{/each}
</select>
<label for="details">What happened</label>
<textarea id="details" name="details" rows="5" minlength="20" required value={form?.values?.details ?? ""}></textarea>
{#if fields.details}<p>{fields.details[0]}</p>{/if}
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
<button>Send report</button>
</form>
{/if}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.
export default defineEventHandler(async (event) => {
const body = await readBody<Record<string, string>>(event);
const { ikiformSecretKey } = useRuntimeConfig(event);
const origin = getRequestHeader(event, "origin");
const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${ikiformSecretKey}`,
...(origin ? { Origin: origin } : {}),
},
body: JSON.stringify({
email: body.email ?? "",
page: body.page ?? "",
severity: body.severity ?? "",
details: body.details ?? "",
_gotcha: body._gotcha ?? "",
}),
});
const result = await response.json();
if (!result.ok) {
throw createError({
status: response.status,
message: String(result.error.message),
data: { fields: result.error.fields ?? {} },
});
}
return { sent: true };
});<script setup lang="ts">
const sent = ref(false);
const sending = ref(false);
const fields = ref<Record<string, string[]>>({});
async function send(event: Event) {
const form = event.target as HTMLFormElement;
sending.value = true;
fields.value = {};
try {
await $fetch("/api/report", { method: "POST", body: Object.fromEntries(new FormData(form)) });
sent.value = true;
} catch (failure) {
const reply = failure as { data?: { data?: { fields?: Record<string, string[]> } } };
fields.value = reply.data?.data?.fields ?? { details: ["The report didn't go through. Try again."] };
} finally {
sending.value = false;
}
}
</script>
<template>
<p v-if="sent" role="status">Thanks. We'll look at that page today.</p>
<form v-else method="post" action="/api/report" @submit.prevent="send">
<label for="email">Your email</label>
<input id="email" name="email" type="email" required />
<p v-if="fields.email">{{ fields.email[0] }}</p>
<label for="page">Page address</label>
<input id="page" name="page" type="url" required />
<p v-if="fields.page">{{ fields.page[0] }}</p>
<label for="severity">How bad is it</label>
<select id="severity" name="severity">
<option value="cosmetic">Looks wrong</option>
<option value="broken" selected>Something doesn't work</option>
<option value="blocking">I can't continue</option>
</select>
<label for="details">What happened</label>
<textarea id="details" name="details" rows="5" minlength="20" required />
<p v-if="fields.details">{{ fields.details[0] }}</p>
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
<button :disabled="sending">{{ sending ? "Sending" : "Send report" }}</button>
</form>
</template>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.
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>
);
}Post browser yang sama di komponen Vue. Formulir mempertahankan action dan method sungguhan, jadi tetap terkirim dengan cara biasa jika script gagal dimuat.
<script setup lang="ts">
import { ref } from "vue";
const ENDPOINT = "https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx";
const sent = ref(false);
const sending = ref(false);
const fields = ref<Record<string, string[]>>({});
const problem = ref("");
async function send(event: SubmitEvent) {
sending.value = true;
fields.value = {};
problem.value = "";
try {
const response = await fetch(ENDPOINT, {
method: "POST",
headers: { Accept: "application/json" },
body: new FormData(event.target as HTMLFormElement),
});
const result = await response.json();
if (result.ok) sent.value = true;
else if (result.error.fields) fields.value = result.error.fields;
else problem.value = result.error.message;
} catch {
problem.value = "Couldn't reach the server. Check your connection and try again.";
} finally {
sending.value = false;
}
}
</script>
<template>
<p v-if="sent" role="status">Thanks. We'll look at that page today.</p>
<form v-else :action="ENDPOINT" method="post" @submit.prevent="send">
<label for="email">Your email</label>
<input id="email" name="email" type="email" required :aria-invalid="Boolean(fields.email)" />
<p v-if="fields.email">{{ fields.email[0] }}</p>
<label for="page">Page address</label>
<input id="page" name="page" type="url" required :aria-invalid="Boolean(fields.page)" />
<p v-if="fields.page">{{ fields.page[0] }}</p>
<label for="severity">How bad is it</label>
<select id="severity" name="severity">
<option value="cosmetic">Looks wrong</option>
<option value="broken" selected>Something doesn't work</option>
<option value="blocking">I can't continue</option>
</select>
<label for="details">What happened</label>
<textarea id="details" name="details" rows="5" minlength="20" required :aria-invalid="Boolean(fields.details)" />
<p v-if="fields.details">{{ fields.details[0] }}</p>
<input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
<p v-if="problem" role="alert">{{ problem }}</p>
<button :disabled="sending">{{ sending ? "Sending" : "Send report" }}</button>
</form>
</template>IKIFORM_SECRET_KEY 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
methodsungguhan dan, jika mengirim langsung ke Ikiform,actionsungguhan. Dengan script mati formulir tetap terkirim dan mendapat303. namesetiap input adalahidfield dalam definisi. Ketidakcocokan, sepertiurlalih-alihpage, mendapat422denganUnknown field "url".- Input
_gotchatersembunyi adalah honeypot. Bot yang mengisinya disimpan sebagai spam, diam-diam. - Versi JavaScript menampilkan
error.fieldsdi 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, Astro, SvelteKit, Nuxt, React, Vue, dan dari server.
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 |
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 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-Typesendiri saat body berupaFormData. Browser mengaturmultipart/form-databeserta boundary-nya; header yang ditulis manual tidak punya boundary dan body tidak bisa di-parse. - Kirim
Accept: application/json. Ikiform toh akan memilih JSON dariSec-Fetch-Mode, tetapi header ini membuatnya eksplisit, dan termasuk header request yang bisa dikirim browser lintas situs tanpa preflight. - Body
FormDataatauURLSearchParamsadalah request lintas situs sederhana, jadi tidak ada preflight. Body JSON denganContent-Type: application/jsonmemicu preflight lebih dulu; Ikiform menjawabnya, tetapi itu menambah satu perjalanan pulang-pergi. form.submit()melewati validasi dan eventsubmit.form.requestSubmit()menjalankan keduanya, seperti klik.- Berikan tombol yang diklik sebagai argumen kedua,
new FormData(form, event.submitter), jikanamedanvalue-nya penting.
Pola lengkapnya, dengan status loading, fokus, dan error jaringan, ada di tanpa reload halaman. Dokumentasi JavaScript dan fetch 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 halamanKirim formulir dengan fetch dan FormData agar pengunjung tetap di halaman, dengan error field, status loading, percobaan ulang, dan cadangan saat script gagal.
- Redirect setelah submitArahkan pengunjung ke halaman terima kasih Anda sendiri setelah formulir terkirim: redirect 303, field _redirect, domain mana yang diizinkan, dan apa efek refresh.
- Atribut actionApa 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 formulirValidasi 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.
- Di Allowed domains formulir, tambahkan domain situs Anda, ditambah
localhostselama pengembangan. Biarkan kosong untuk menerima kiriman dari mana saja. - Matikan JavaScript di developer tools browser Anda dan kirim formulir. Formulir tetap harus mendarat di halaman terima kasih Anda dengan
303. - Dengan JavaScript aktif, buka panel Network dan kirim lagi. Request ke
/f/<formId>seharusnya menjawab201dengan JSON, dan halaman tidak dimuat ulang. - Kirim dengan pesan yang terlalu pendek. Post biasa menampilkan halaman error Ikiform dengan label field Anda; versi script menampilkan pesan di bawah field.
- Di halaman terima kasih, tekan refresh. Tidak boleh ada yang terkirim ulang, dan tidak boleh muncul kiriman kedua.
- Buka Submissions di dashboard. Kiriman baru ada di sana dengan setiap field yang Anda kirim.
- 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
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.
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.
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.
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.
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.
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.
Belum. Kiriman yang berisi file ditolak dengan 422 files_not_supported. Minta link ke file sebagai gantinya, seperti URL shared drive.
Mentok di salah satu langkah, atau ingin panduan untuk situasi Anda? Email hello@ikiform.com.