# 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.

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

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.

Diagram: What a browser does between the submit click and the next page

1. A submit button, the Enter key in a text field, or form.requestSubmit() starts the submission and fires a submit event.
2. Unless the form has novalidate, the browser checks every control's constraints. The first invalid one gets a message and nothing is sent.
3. The browser builds the form data set from every control that has a name and isn't disabled.
4. It encodes that set with the form's enctype, urlencoded unless you say otherwise.
5. It navigates to the action URL with the form's method, sending the body.
6. The server answers. A 303 tells the browser to load another URL with GET, which is the page the visitor ends up on.

Everything above the line happens with no network at all. A submit event listener can stop the flow at the first step and take over with fetch.

## 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](https://ikiform.com/guides/html-form-submission/action-attribute.md) | `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:

`Two submit buttons`:

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

### 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`. An `id` alone sends nothing.
- `disabled` controls are skipped. Use `readonly` if you want a value shown, locked and sent.
- An unchecked checkbox or radio sends nothing at all, not `false`. A checked checkbox with no `value` sends `on`.
- Only the button that submitted the form is included, with its `name` and `value`.
- `<select multiple>` and checkbox groups that share a name send one pair per selected value.
- Inputs outside the `<form>` element can join it with a `form` attribute that names the form's `id`.

### 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:

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

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

> Whatever the encoding, keep the page in UTF-8. Ikiform decodes every body as UTF-8 and refuses other charsets with `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](https://ikiform.com/guides/html-form-submission/without-page-reload.md) |
| 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.

Diagram: Answering a POST with a page compared with answering it with a 303 redirect

1. With a 200 answer, the browser posts the form and shows the response body as the page.
2. That page is tied to the POST, so a refresh asks to resubmit and a confirm sends the data again.
3. With a 303 answer, the browser posts the form and is told to load another URL.
4. It loads that URL with GET. A refresh repeats only the GET, so nothing is sent twice.

Ikiform always answers a browser post with a 303, so your form gets this behaviour for free.

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

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

## How 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](https://ikiform.com/docs/how-it-works.md#response-modes).

Diagram: The same Ikiform endpoint answering a plain form post and a fetch call

1. A plain form post navigates the tab, so the browser sends Sec-Fetch-Mode: navigate.
2. Ikiform answers in page mode: a 303 redirect on success, or a hosted error page with the error's real status.
3. A fetch call sends Sec-Fetch-Mode: cors, or your code asks for JSON with the Accept header.
4. Ikiform answers in JSON mode: 201 with the submission id, or the error envelope with field errors keyed by field id.

You don't configure the mode. It follows from how the request was made.

### Where a successful post lands

A successful browser post gets a `303`. Ikiform picks the target in this order:

1. The form's **Redirect after submit** setting in the dashboard. Any `http:` or `https:` URL works, because only your workspace can set it.
2. A `_redirect` field in the post, or `_next` if there is no `_redirect`, when it is an `https:` URL with no username, password or custom port, on one of the form's **Allowed domains** or a subdomain of one.
3. Otherwise the hosted thank-you page at `/f/<formId>/thanks`. When the post came from an `https:` 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](https://ikiform.com/guides/html-form-submission/redirect-after-submit.md) covers thank-you pages in depth, and the full rules are in [HTML forms](https://ikiform.com/docs/html.md#redirect-rules).

### 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](https://ikiform.com/docs/errors.md).

`422 in JSON mode`:

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

Form: Report a broken page

- **Your email** (`email`, email, required)
- **Page address** (`page`, url, required)
- **How bad is it** (`severity`, select, required)
- **What happened** (`details`, textarea, required)

Paste this definition on the form's **Fields** tab in the dashboard and Ikiform validates every post against it. See [Fields and validation](https://ikiform.com/docs/fields.md).

**HTML**

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.

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

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.

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

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.

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

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.

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

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.

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

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.

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

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.

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

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.

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

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.

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

> The server-side versions post with a secret key, so Ikiform skips **Allowed domains** and its human check. Every post then comes from your server's IP, and the Standard limit of 5 a minute per visitor counts all your visitors together. Choose the Relaxed or Custom [rate limit](https://ikiform.com/docs/rate-limits.md) for a form you post through a server, and keep `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 `method` and, where it posts straight to Ikiform, a real `action`. With scripts off the form still submits and gets the `303`.
- The `name` of each input is the field `id` in the definition. A mismatch, such as `url` instead of `page`, gets `422` with `Unknown field "url"`.
- The hidden `_gotcha` input is the honeypot. Bots that fill it in are stored as spam, quietly.
- The JavaScript versions show `error.fields` under 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](https://ikiform.com/docs/guides/nextjs.md), [Astro](https://ikiform.com/docs/guides/astro.md), [SvelteKit](https://ikiform.com/docs/guides/sveltekit.md), [Nuxt](https://ikiform.com/docs/guides/nuxt.md), [React](https://ikiform.com/docs/guides/react.md), [Vue](https://ikiform.com/docs/guides/vue.md) and [from a server](https://ikiform.com/docs/guides/server.md).

## 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` |
| Email | `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](https://ikiform.com/guides/html-form-submission/validation.md) 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-Type` yourself when the body is `FormData`. The browser sets `multipart/form-data` with the boundary; a hand-written header has no boundary and the body can't be parsed.
- Send `Accept: application/json`. Ikiform would pick JSON from `Sec-Fetch-Mode` anyway, but the header makes it explicit, and it's one of the request headers browsers can send cross-site without a preflight.
- A `FormData` or `URLSearchParams` body is a simple cross-site request, so there's no preflight. A JSON body with `Content-Type: application/json` triggers a preflight first; Ikiform answers it, but it costs a round trip.
- `form.submit()` skips validation and the `submit` event. `form.requestSubmit()` runs both, as a click would.
- Pass the clicked button as the second argument, `new FormData(form, event.submitter)`, if its `name` and `value` matter.

The full pattern, with loading state, focus and network errors, is in [without a page reload](https://ikiform.com/guides/html-form-submission/without-page-reload.md). The [JavaScript and fetch](https://ikiform.com/docs/frameworks.md) 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 reload](https://ikiform.com/guides/html-form-submission/without-page-reload.md): Send 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 submit](https://ikiform.com/guides/html-form-submission/redirect-after-submit.md): Send 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 attribute](https://ikiform.com/guides/html-form-submission/action-attribute.md): What 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 validation](https://ikiform.com/guides/html-form-submission/validation.md): Validate 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](https://ikiform.com/pricing).

1. Under the form's **Allowed domains**, add your site's domain, plus `localhost` while you develop. Leave it empty to accept posts from anywhere.
2. 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`.
3. With JavaScript on, open the Network panel and submit again. The request to `/f/<formId>` should answer `201` with JSON, and the page shouldn't reload.
4. 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.
5. On the thank-you page, press refresh. Nothing should be resubmitted, and no second submission should appear.
6. Open **Submissions** in the dashboard. The new submission is there with every field you sent.
7. 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

### What does the action attribute do in an HTML form?

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.

### Should a form use GET or POST?

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.

### Why does refreshing the page submit my form again?

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.

### Can an HTML form send JSON?

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.

### Is the required attribute enough to validate a form?

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.

### Can I submit a form without a backend?

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.

### Can the form accept file uploads?

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.

## More guides

- [Without a page reload](https://ikiform.com/guides/html-form-submission/without-page-reload.md): Send 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 submit](https://ikiform.com/guides/html-form-submission/redirect-after-submit.md): Send 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 attribute](https://ikiform.com/guides/html-form-submission/action-attribute.md): What 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 validation](https://ikiform.com/guides/html-form-submission/validation.md): Validate a form with required, type, pattern and min, style errors with :user-invalid, and enforce the same rules on the server with field definitions.
- [Event registration](https://ikiform.com/guides/event-registration.md): Take RSVPs on your own event page: the form, guest limits, an honest headcount, closing when full and a check-in list, without a ticketing app.
- [Verify webhook signatures](https://ikiform.com/guides/webhook-signature-verification.md): Check that a form webhook really came from Ikiform: the Standard Webhooks HMAC-SHA256 scheme, a test vector, raw bodies, timestamps and secret rotation.
- [Waitlist for a new SaaS](https://ikiform.com/guides/waitlist.md): Build a pre-launch waitlist on your own landing page: the form, spam protection, duplicates, confirmation emails and a clean export on launch day.
