Guides15 min read
How HTML form submission works
What a browser does when a form is submitted: which fields it sends, how it encodes them, what the server should answer, and how Ikiform answers.
When someone presses submit, the browser checks the form's constraints, collects every named and enabled control, encodes them with the form's enctype, and sends them to the action URL with the form's method. The server answers like any page load, and a well-behaved one answers a successful POST with a 303 redirect, so a refresh doesn't send the form twice. Point action at a form backend such as Ikiform and you get that redirect, field validation and spam filtering with no server of your own.
HTML forms are older than JavaScript and still the most reliable way to send data from a web page. They work with scripts off, on slow phones and in every builder that lets you paste markup. Most form bugs come from not knowing what the browser does on its own, so this guide walks through a submission step by step, then shows how a form backend fits in.
What happens when you press submit
A submission is a normal navigation with a body attached. The browser does four things before anything leaves the page, then hands the request to the network and loads whatever comes back.
- A submit button, the Enter key in a text field, or form.requestSubmit() starts the submission and fires a submit event.
- Unless the form has novalidate, the browser checks every control's constraints. The first invalid one gets a message and nothing is sent.
- The browser builds the form data set from every control that has a name and isn't disabled.
- It encodes that set with the form's enctype, urlencoded unless you say otherwise.
- It navigates to the action URL with the form's method, sending the body.
- The server answers. A 303 tells the browser to load another URL with GET, which is the page the visitor ends up on.
The attributes that decide the request
Five attributes on <form> decide where the data goes and how. Each one can be overridden for a single button with a matching form* attribute, which is how one form can have Send and Save draft buttons that behave differently.
| Attribute | Default | What it does | Button override |
|---|---|---|---|
action | The page's own URL | The URL that receives the data. Relative values resolve against the page. See the action attribute | formaction |
method | get | get puts the fields in the URL's query string. post sends them as the body. dialog closes a <dialog> without sending anything | formmethod |
enctype | application/x-www-form-urlencoded | How a post body is encoded. multipart/form-data is needed for files. text/plain is meant for debugging | formenctype |
novalidate | Off | Skips the browser's constraint check on submit | formnovalidate |
target | _self | Where the response loads. _blank opens a new tab, a named iframe keeps the page in place | formtarget |
Two buttons, two behaviours. The second one skips validation, and the clicked button's own name and value are added to the data, so the server knows which was pressed:
<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>Which controls are sent
Browsers only send what the HTML spec calls the form data set. Most surprises in a submission log come from these rules:
- A control needs a
name. Anidalone sends nothing. disabledcontrols are skipped. Usereadonlyif you want a value shown, locked and sent.- An unchecked checkbox or radio sends nothing at all, not
false. A checked checkbox with novaluesendson. - Only the button that submitted the form is included, with its
nameandvalue. <select multiple>and checkbox groups that share a name send one pair per selected value.- Inputs outside the
<form>element can join it with aformattribute that names the form'sid.
How the body is encoded
With the default enctype, the body is one line of name=value pairs joined by &, with spaces as + and everything else outside a safe set percent-encoded. This is the real request for the report form further down, as the browser sends it:
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 wraps each field in its own part with a boundary string the browser picks. It's larger, but it can carry files. Ikiform reads both. text/plain has no reliable way to tell where a value ends, so Ikiform refuses it with 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. File uploads aren't supported yet. A post with a file gets 422 files_not_supported.Four ways to submit a form
A plain post is only one option. Each approach below is a fair choice somewhere; what changes is who handles the answer and what happens with scripts off.
| Approach | Good at | Trade-offs |
|---|---|---|
Plain method="post" with a redirect | Works with no JavaScript, in any builder. The browser handles loading state, back and refresh | A full page load. Field errors need a server-rendered page, or the backend's error page |
fetch with FormData | No reload. You decide what success and each field error look like | You own the loading state, network errors and focus. Without care it breaks with scripts off. See without a page reload |
| Framework form actions, such as Next.js server actions or SvelteKit form actions | Your server sees the post first, and the same form works before and after hydration | You need a server runtime, and the request to the backend leaves from your server, which changes rate limits and origin checks |
method="get" | Search and filter forms. The result has a shareable URL | Fields land in the URL, history and server logs. Never for personal data or anything that changes state |
Start with the plain post and add fetch on top. That way the form still works when a script fails to load, which happens more often than most analytics show.
What the server should answer
The browser treats the answer to a POST as a new page. If that page is the response itself, with status 200, the browser remembers that it came from a POST. Press refresh and it offers to send the form again; press back and forward and some browsers do the same. That's how people pay twice and support gets the same message three times.
The fix is the Post/Redirect/Get pattern. The server handles the POST, then answers 303 See Other with a Location header. The browser loads that URL with a GET, and the page the visitor sees came from a GET, so refresh is harmless.
- With a 200 answer, the browser posts the form and shows the response body as the page.
- That page is tied to the POST, so a refresh asks to resubmit and a confirm sends the data again.
- With a 303 answer, the browser posts the form and is told to load another URL.
- It loads that URL with GET. A refresh repeats only the GET, so nothing is sent twice.
| Status | What the browser does next | Use it for |
|---|---|---|
303 See Other | GET the Location, body dropped | After any successful form post |
302 Found | Browsers switch to GET in practice, but the spec allows keeping the method | Older servers. Prefer 303 |
307 and 308 | Repeat the same POST with the same body at the new URL | Moving an endpoint, never a thank-you page |
200 with HTML | Show the page; refresh offers to post again | Re-showing the form with errors |
4xx with HTML | Show the error page | Errors the visitor can fix by going back |
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.comHow Ikiform answers
One endpoint, https://ikiform.com/f/<formId>, answers both browsers and code. It decides per request whether to answer with pages or with JSON, from headers the browser sets on its own:
| Request | Answer |
|---|---|
Accept includes application/json | JSON |
| The body is JSON | JSON |
Sec-Fetch-Mode: navigate, which is every plain form post | Redirect or hosted page |
Any other Sec-Fetch-Mode, such as cors from fetch | JSON |
No Sec-Fetch-Mode, and Accept includes text/html | Redirect or hosted page |
| Anything else, such as curl | JSON |
Sec-Fetch-Mode is a header the browser sets and page scripts can't change. A form post that navigates the tab sends navigate; a fetch call sends cors for a cross-site request. So the same form gets a redirect when it posts the plain way and JSON when your script posts it, without any switch on your side. The full table is in JSON or a redirect.
- A plain form post navigates the tab, so the browser sends Sec-Fetch-Mode: navigate.
- Ikiform answers in page mode: a 303 redirect on success, or a hosted error page with the error's real status.
- A fetch call sends Sec-Fetch-Mode: cors, or your code asks for JSON with the Accept header.
- Ikiform answers in JSON mode: 201 with the submission id, or the error envelope with field errors keyed by field id.
Where a successful post lands
A successful browser post gets a 303. Ikiform picks the target in this order:
- The form's Redirect after submit setting in the dashboard. Any
http:orhttps:URL works, because only your workspace can set it. - A
_redirectfield in the post, or_nextif there is no_redirect, when it is anhttps:URL with no username, password or custom port, on one of the form's Allowed domains or a subdomain of one. - Otherwise the hosted thank-you page at
/f/<formId>/thanks. When the post came from anhttps:page on an allowed domain, it links back there.
A _redirect that breaks a rule is ignored, never an error, and a form with no allowed domains ignores _redirect entirely. That stops anyone from using your form to bounce visitors to a site of their choosing. Redirect after submit covers thank-you pages in depth, and the full rules are in HTML forms.
When a post is refused
In page mode, a refused post shows a hosted error page with the error's real HTTP status. For a validation error it lists each problem under your field's label and links to the page the post came from. The browser's Back button usually returns to the form with what the visitor typed still there; the link loads the page fresh. In JSON mode the same error comes back as one envelope. Every code is on 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 is the one thing that never looks like an error. A post that scores as spam gets the same 303 or 201 as a real one, so a bot learns nothing, and it sends no email or webhook.
The form in your stack
Here is a real form, a broken-page report, in every stack. Pick yours; the choice carries over to every code sample in these guides. The field definition below is the same one Ikiform enforces on the server, so try it: leave the details short or type example as the address and you get the 422 your form would get.
Preview
Runs Ikiform's validator in your browser. Nothing is sent.
No script at all. The browser checks required, type and minlength, posts the fields as urlencoded text, follows Ikiform's 303 to your _redirect page, and shows Ikiform's error page if the server refuses the 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>A server action receives the FormData, forwards it as JSON with your secret key and returns Ikiform's field errors. useActionState puts each message under its input and keeps what the visitor typed.
"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>
);
}With accept: "form", Zod rejects a bad email or URL before anything leaves your server, and isInputError exposes those messages per field. Ikiform's own 422 field errors come back as action data in the same shape.
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>
)}A POST server function reads the FormData in .validator() and returns the field errors as plain data, so the component can render them without catching anything.
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>
);
}The route action is the form's real target, so <Form> works before hydration too. data() passes Ikiform's 422 status through with the field errors and the typed values.
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>
);
}The default form action returns fail() with Ikiform's status and field errors. Without JavaScript the page re-renders with them; with use:enhance it updates in place. Declare IKIFORM_SECRET_KEY in src/env.ts with defineEnvVars so $app/env/private can read it.
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}A server route forwards the report with runtimeConfig.ikiformSecretKey, set from NUXT_IKIFORM_SECRET_KEY. It throws createError with the field errors in data, which $fetch hands back on the client.
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>A single-page app posts from the browser, so there is no secret key here; Allowed domains protects the form. Accept: application/json gets a JSON answer instead of a 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>
);
}The same browser post in a Vue component. The form keeps a real action and method, so it still submits the plain way if the script fails to load.
<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 in server-only environment variables.Each server version forwards the browser's Origin header when there is one, because a server-side fetch sends none and Ikiform uses it to recognise free localhost tests on a workspace without a plan.
- Every version keeps a real
methodand, where it posts straight to Ikiform, a realaction. With scripts off the form still submits and gets the303. - The
nameof each input is the fieldidin the definition. A mismatch, such asurlinstead ofpage, gets422withUnknown field "url". - The hidden
_gotchainput is the honeypot. Bots that fill it in are stored as spam, quietly. - The JavaScript versions show
error.fieldsunder each input, using the first message for each field. The plain HTML version leaves that to Ikiform's error page.
The docs go deeper per framework: Next.js, Astro, SvelteKit, Nuxt, React, Vue and from a server.
Two layers of validation
Browser validation and server validation do different jobs. The browser's constraint check (required, type="email", minlength, pattern) gives instant feedback, but anyone can skip it: a bot posts directly, and a script can call form.submit(), which never runs the check. The server is the only place a rule actually holds.
| Rule | In the browser | In Ikiform's field definition |
|---|---|---|
| Required | required | "required": true |
type="email" | "type": "email", with optional allowed_domains | |
| URL | type="url" | "type": "url", with optional https_only |
| Length | minlength, maxlength | validation.min_length, max_length |
| Only listed choices | A <select>, which a bot ignores | options; anything else is refused |
| No extra fields | Not possible | Any field not in the definition is refused |
Without a definition, Ikiform accepts any fields and stores them as they arrive. Publish one and it validates, stores numbers and checkboxes as real numbers and booleans, and refuses the post with 422 validation_failed before anything is stored. Validation shows how to keep both layers saying the same thing.
Submitting with JavaScript
To keep the visitor on the page, listen for submit, call preventDefault(), and send new FormData(form) with fetch. A few details decide whether that works first time:
- Don't set
Content-Typeyourself when the body isFormData. The browser setsmultipart/form-datawith the boundary; a hand-written header has no boundary and the body can't be parsed. - Send
Accept: application/json. Ikiform would pick JSON fromSec-Fetch-Modeanyway, but the header makes it explicit, and it's one of the request headers browsers can send cross-site without a preflight. - A
FormDataorURLSearchParamsbody is a simple cross-site request, so there's no preflight. A JSON body withContent-Type: application/jsontriggers a preflight first; Ikiform answers it, but it costs a round trip. form.submit()skips validation and thesubmitevent.form.requestSubmit()runs both, as a click would.- Pass the clicked button as the second argument,
new FormData(form, event.submitter), if itsnameandvaluematter.
The full pattern, with loading state, focus and network errors, is in without a page reload. The JavaScript and fetch docs list every header and body format the endpoint takes.
Pick your situation
Each of these pages takes one part of a submission and goes further than the overview above, with code for that case.
- Without a page reloadSend a form with fetch and FormData so the visitor stays on the page, with field errors, a loading state, retries and a fallback when scripts fail.
- Redirect after submitSend visitors to your own thank-you page after a form posts: the 303 redirect, the _redirect field, which domains are allowed and what refresh does.
- The action attributeWhat a form's action attribute does, how relative and empty values resolve, how formaction sends one form to two places, and why static hosts answer 405.
- Form validationValidate a form with required, type, pattern and min, style errors with :user-invalid, and enforce the same rules on the server with field definitions.
Check it works
Run through this once per form, in a private window. Ikiform has no free plan. Testing is free: a workspace without a plan accepts 50 test submissions a month from localhost. To go live, pick Pro from $6/mo for 500 submissions, or pay once: 10,000 submissions for $49 or 50,000 submissions for $149, with credits that never expire. See pricing.
- Under the form's Allowed domains, add your site's domain, plus
localhostwhile you develop. Leave it empty to accept posts from anywhere. - Turn JavaScript off in your browser's developer tools and submit the form. It should still land on your thank-you page with a
303. - With JavaScript on, open the Network panel and submit again. The request to
/f/<formId>should answer201with JSON, and the page shouldn't reload. - Submit with a too-short message. The plain post shows Ikiform's error page with your field label; the scripted one shows the message under the field.
- On the thank-you page, press refresh. Nothing should be resubmitted, and no second submission should appear.
- Open Submissions in the dashboard. The new submission is there with every field you sent.
- Post with the honeypot filled in,
_gotcha=bot. It still gets a success answer, but the submission is stored as spam and sends no email or webhook.
Troubleshooting
| Problem | What to do |
|---|---|
405 Method Not Allowed after submit | The form posted to your own static host, which can't take POST. Check action: it must be the full https://ikiform.com/f/<formId> URL, not a relative path or empty. |
Opening the endpoint in a tab shows 404 not_found | Expected. A tab sends GET, and the endpoint only takes POST. Check the form's method is post; a form with no method sends GET with the fields in the URL. |
415 unsupported_media_type | The form has enctype="text/plain", or a script sent a type Ikiform doesn't read. Remove the enctype, or use multipart/form-data. |
422 validation_failed | The post doesn't match the form's field definition. error.fields names each field and the reason. Match the input name to the field id. |
| The browser lands on Ikiform's thank-you page instead of yours | _redirect is ignored unless its domain is in Allowed domains and it is an https: URL. Add the domain, or set Redirect after submit in the form's settings instead. |
| A field is missing from the submission | It has no name, it's disabled, or it's an unchecked checkbox. Give it a name, use readonly instead of disabled, or define a checkbox field so an unchecked box is stored as false. |
| Refresh asks to resubmit the form | The page answered the POST with 200. Posts straight to Ikiform always get a 303; if your own server sits in between, redirect after handling the post. |
403 origin_not_allowed, or every post lands in spam | The page's domain isn't in the form's Allowed domains. Add it, plus localhost for local tests. Subdomains of a listed domain are allowed. |
Questions
It's the URL that receives the form's data. A relative value resolves against the page's address, and a form with no action posts to the page itself. A submit button can override it with formaction.
POST for anything that sends personal data or changes state, such as contact, signup or order forms. GET is for search and filter forms, where the fields belong in a URL people can share.
The page you see is the direct response to a POST, so the browser offers to repeat it. Answer a successful POST with a 303 redirect to a thank-you page instead. Ikiform does this for every browser post.
Not on its own. A form sends urlencoded or multipart data. To send JSON, read the form with FormData in a submit handler and post JSON.stringify(Object.fromEntries(data)) with fetch. Ikiform accepts all three.
No. It helps people, but bots post directly and skip it. Validate on the server too. With Ikiform, define the fields and every post is checked against them before it's stored.
You need something to receive the POST, but it doesn't have to be your server. Point the form's action at a form backend such as Ikiform, and the page can stay fully static.
Not yet. A post that contains a file is refused with 422 files_not_supported. Ask for a link to the file instead, such as a shared drive URL.
Stuck on a step, or want a guide for your situation? Email hello@ikiform.com.