# File upload form: collect files with a form backend

> Ikiform doesn't store files yet. This form collects a link to the file, and shows how to upload to your own storage first and submit the URL.

Updated 2026-10-24 · https://ikiform.com/forms/file-upload

## Copy the code

Every version below is generated from the same field definition. Replace `frm_xxxxxxxxxxxxxxxx` with your form id, or enter it in the **Form ID** box and the code updates.

**HTML** (`file-upload.html`)

```html
<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="post">
  <label for="name">Name</label>
  <input id="name" name="name" type="text" maxlength="120" required />
  <label for="email">Email</label>
  <input id="email" name="email" type="email" autocomplete="email" required />
  <label for="file_url">Link to the file</label>
  <input id="file_url" name="file_url" type="url" required aria-describedby="file_url-help" />
  <p id="file_url-help">A shared link, for example Google Drive, Dropbox or WeTransfer.</p>
  <label for="notes">Anything we should know?</label>
  <textarea id="notes" name="notes" rows="4" maxlength="4000"></textarea>
  <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
  <button>Send</button>
</form>
```

**React** (`FileUploadForm.tsx`)

```tsx
import { submit } from "@ikiform/sdk";
import { type FormEvent, useState } from "react";

const FORM_ID = "frm_xxxxxxxxxxxxxxxx";

type Status = "idle" | "sending" | "sent" | "error";

export function FileUploadForm() {
  const [status, setStatus] = useState<Status>("idle");
  const [errors, setErrors] = useState<Record<string, string[]>>({});
  const [message, setMessage] = useState("");

  async function onSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    setStatus("sending");
    setErrors({});
    const result = await submit(FORM_ID, new FormData(event.currentTarget));
    if (result.ok) return setStatus("sent");
    setErrors(result.error.fields ?? {});
    setMessage(result.error.message);
    setStatus("error");
  }

  if (status === "sent") return <p>Thanks, we got your file.</p>;

  return (
    <form onSubmit={onSubmit}>
      <label htmlFor="name">Name</label>
      <input id="name" name="name" type="text" maxLength={120} required />
      {errors.name ? <p>{errors.name[0]}</p> : null}
      <label htmlFor="email">Email</label>
      <input id="email" name="email" type="email" autoComplete="email" required />
      {errors.email ? <p>{errors.email[0]}</p> : null}
      <label htmlFor="file_url">Link to the file</label>
      <input id="file_url" name="file_url" type="url" required aria-describedby="file_url-help" />
      <p id="file_url-help">A shared link, for example Google Drive, Dropbox or WeTransfer.</p>
      {errors.file_url ? <p>{errors.file_url[0]}</p> : null}
      <label htmlFor="notes">Anything we should know?</label>
      <textarea id="notes" name="notes" rows={4} maxLength={4000} />
      {errors.notes ? <p>{errors.notes[0]}</p> : null}
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      {status === "error" && !Object.keys(errors).length ? <p role="alert">{message}</p> : null}
      <button disabled={status === "sending"}>{status === "sending" ? "Sending" : "Send"}</button>
    </form>
  );
}
```

**JavaScript** (`file-upload.js`)

```js
const form = document.querySelector('form[action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx"]');
const status = document.createElement("p");
status.setAttribute("role", "alert");
form.append(status);

const labels = {
  name: "Name",
  email: "Email",
  file_url: "Link to the file",
  notes: "Anything we should know?",
};

form.addEventListener("submit", async (event) => {
  event.preventDefault();
  const button = form.querySelector("button");
  button.disabled = true;
  status.textContent = "";
  try {
    const response = await fetch(form.action, {
      method: "POST",
      headers: { Accept: "application/json" },
      body: new FormData(form),
    });
    const result = await response.json();
    if (result.ok) {
      form.replaceWith("Thanks, we got your file.");
      return;
    }
    const fields = Object.entries(result.error.fields ?? {});
    status.textContent = fields.length
      ? fields.map(([id, messages]) => `${labels[id] ?? id}: ${messages[0]}`).join(" ")
      : result.error.message;
  } catch {
    status.textContent = "Couldn't reach the server. Try again.";
  }
  button.disabled = false;
});
```

- **HTML** needs no JavaScript. After a successful post the browser is redirected to the form's redirect URL, or to Ikiform's hosted thank-you page.
- **React** uses the SDK: `npm install @ikiform/sdk`. Field errors from the server appear under each field.
- **JavaScript** enhances the HTML form with `fetch`, so the visitor stays on the page.
- The hidden `_gotcha` input is the honeypot. Leave it empty and hidden.

> If you set the form's spam check to **Ikiform**, the HTML form keeps working with no change and the server action skips the check because it sends a secret key. The React and JavaScript versions must get a token before each submit: call `challenge(FORM_ID)` from `@ikiform/sdk` and pass it to `submit` as `turnstileToken`, or add it to the `fetch` body as `cf-turnstile-response`. Without it the post is refused with `403 challenge_required`. See [the Next.js guide](https://ikiform.com/docs/guides/nextjs.md#client-human-check) and [Spam protection](https://ikiform.com/docs/spam.md#human-check).

## The fields

Paste this definition on the form's **Fields** tab in the dashboard. Ikiform then checks every post against it on the server, refuses bad data with `422 validation_failed`, refuses any field that isn't listed, and stores typed values.

| Field id | Type | Required | Accepts |
| --- | --- | --- | --- |
| `name` | `text` | Yes | Up to 120 characters |
| `email` | `email` | Yes | One valid address |
| `file_url` | `url` | Yes | An `https:` URL |
| `notes` | `textarea` | No | Up to 4,000 characters |

`Field definition`:

```json
{
  "version": 1,
  "title": "File upload",
  "fields": [
    {
      "id": "name",
      "type": "text",
      "label": "Name",
      "required": true,
      "validation": {
        "max_length": 120
      }
    },
    {
      "id": "email",
      "type": "email",
      "label": "Email",
      "required": true
    },
    {
      "id": "file_url",
      "type": "url",
      "label": "Link to the file",
      "required": true,
      "help_text": "A shared link, for example Google Drive, Dropbox or WeTransfer.",
      "validation": {
        "https_only": true
      }
    },
    {
      "id": "notes",
      "type": "textarea",
      "label": "Anything we should know?",
      "rows": 4,
      "validation": {
        "max_length": 4000
      }
    }
  ],
  "settings": {
    "submit_label": "Send",
    "success_message": "Thanks, we got your file."
  }
}
```

## Next.js server action

To keep the form working without JavaScript in a Next.js app, post from a server action with a secret key. Create the key under **API keys** with the `submissions:write` scope and keep it out of the browser.

`.env.local`:

```bash
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
```

`app/file-upload/actions.ts`:

```ts
"use server";

export interface FileUploadFormState {
  ok: boolean;
  message: string;
  fields?: Record<string, string[]>;
  values?: Record<string, string[]>;
}

export async function sendFileUploadForm(_previous: FileUploadFormState, formData: FormData): Promise<FileUploadFormState> {
  const body = new URLSearchParams();
  for (const [name, value] of formData) {
    if (typeof value === "string" && !name.startsWith("$ACTION")) body.append(name, value);
  }
  const response = await fetch("https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx", {
    method: "POST",
    headers: {
      Accept: "application/json",
      Authorization: `Bearer ${process.env.IKIFORM_SECRET_KEY}`,
    },
    body,
  });
  const result = await response.json();
  if (result.ok) return { ok: true, message: "Thanks, we got your file." };
  const values = Object.fromEntries([...new Set(body.keys())].map((name) => [name, body.getAll(name)]));
  return { ok: false, message: result.error.message, fields: result.error.fields, values };
}
```

`app/file-upload/form.tsx`:

```tsx
"use client";

import { useActionState } from "react";
import { type FileUploadFormState, sendFileUploadForm } from "./actions";

const initial: FileUploadFormState = { ok: false, message: "" };

export function FileUploadForm() {
  const [state, action, pending] = useActionState(sendFileUploadForm, initial);

  if (state.ok) return <p>{state.message}</p>;

  return (
    <form action={action}>
      <label htmlFor="name">Name</label>
      <input id="name" name="name" type="text" maxLength={120} required defaultValue={state.values?.name?.[0]} />
      {state.fields?.name ? <p>{state.fields.name[0]}</p> : null}
      <label htmlFor="email">Email</label>
      <input id="email" name="email" type="email" autoComplete="email" required defaultValue={state.values?.email?.[0]} />
      {state.fields?.email ? <p>{state.fields.email[0]}</p> : null}
      <label htmlFor="file_url">Link to the file</label>
      <input id="file_url" name="file_url" type="url" required aria-describedby="file_url-help" defaultValue={state.values?.file_url?.[0]} />
      <p id="file_url-help">A shared link, for example Google Drive, Dropbox or WeTransfer.</p>
      {state.fields?.file_url ? <p>{state.fields.file_url[0]}</p> : null}
      <label htmlFor="notes">Anything we should know?</label>
      <textarea id="notes" name="notes" rows={4} maxLength={4000} defaultValue={state.values?.notes?.[0]} />
      {state.fields?.notes ? <p>{state.fields.notes[0]}</p> : null}
      <input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
      {state.message && !state.fields ? <p role="alert">{state.message}</p> : null}
      <button disabled={pending}>{pending ? "Sending" : "Send"}</button>
    </form>
  );
}
```

> A server action posts from your server's IP, so the per-visitor rate limit counts every visitor together. Use the Relaxed or Custom [rate limit](https://ikiform.com/docs/rate-limits.md) for forms you send through a server.

## Set it up

1. Create a form in the [dashboard](https://ikiform.com/dashboard) and copy its id.
2. Paste the field definition on the **Fields** tab.
3. Add your site under **Allowed domains**, plus `localhost` while you develop.
4. Set the rate limit to **Standard, 5 a minute per visitor** if visitors post from the browser. Uploads are occasional. Standard is plenty. With the server action, every post comes from your server's IP, so choose Relaxed or Custom instead.
5. Paste the code, send a test and check it arrives in the dashboard.

## What Ikiform does with files today

Ikiform doesn't accept file uploads yet. A post that contains a real file is refused with `422 files_not_supported`; an empty file input is ignored.

| You post | Result |
| --- | --- |
| A form with a file selected | `422 files_not_supported` |
| A form with an empty file input | Accepted; the input is ignored |
| A file input with no `name` | Accepted; browsers don't send unnamed inputs |
| A link to the file in a `url` field | Validated and stored |

So there are two ways to collect files. The form above asks for a shared link, which needs no code at all. Or upload the file to storage you control and submit only its URL, shown below.

## Upload to your own storage first

Give the file input no `name`, so the browser never sends the file to Ikiform. On submit, ask your server for a presigned upload URL, upload the file there, then submit the form with the file's URL in `file_url`.

**HTML** (`upload.html`)

```html
<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="post">
  <label for="name">Name</label>
  <input id="name" name="name" type="text" maxlength="120" required />
  <label for="email">Email</label>
  <input id="email" name="email" type="email" autocomplete="email" required />
  <label for="file">File</label>
  <input id="file" type="file" required />
  <input name="file_url" type="hidden" />
  <label for="notes">Anything we should know?</label>
  <textarea id="notes" name="notes" rows="4" maxlength="4000"></textarea>
  <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
  <button>Upload</button>
</form>
```

**JavaScript** (`upload.js`)

```js
import { submit } from "@ikiform/sdk";

const FORM_ID = "frm_xxxxxxxxxxxxxxxx";
const form = document.querySelector('form[action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx"]');
const status = document.createElement("p");
status.setAttribute("role", "alert");
form.append(status);

const labels = {
  name: "Name",
  email: "Email",
  file_url: "Link to the file",
  notes: "Anything we should know?",
};

async function upload(file) {
  const response = await fetch("/api/uploads", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ name: file.name, type: file.type, size: file.size }),
  });
  if (!response.ok) throw new Error("Upload refused");
  const { putUrl, fileUrl } = await response.json();
  const put = await fetch(putUrl, { method: "PUT", headers: { "Content-Type": file.type }, body: file });
  if (!put.ok) throw new Error("Upload failed");
  return fileUrl;
}

form.addEventListener("submit", async (event) => {
  event.preventDefault();
  const button = form.querySelector("button");
  button.disabled = true;
  status.textContent = "";
  try {
    const data = new FormData(form);
    data.set("file_url", await upload(form.querySelector("#file").files[0]));
    const result = await submit(FORM_ID, data);
    if (result.ok) {
      form.replaceWith("Thanks, we got your file.");
      return;
    }
    const fields = Object.entries(result.error.fields ?? {});
    status.textContent = fields.length
      ? fields.map(([id, messages]) => `${labels[id] ?? id}: ${messages[0]}`).join(" ")
      : result.error.message;
  } catch {
    status.textContent = "The file couldn't be uploaded. Try again.";
  }
  button.disabled = false;
});
```

- `/api/uploads` is your own endpoint. It checks the file type and size, creates a presigned `PUT` URL for S3, Cloudflare R2 or similar, and returns it with the URL the file will have.
- Keep `file_url` in the field definition as a `url` field so Ikiform still validates it.
- If the file input had a `name`, the SDK would send the file and Ikiform would refuse the post with `422 files_not_supported`.

## Where to send it

Every submission can go to a signed [webhook](https://ikiform.com/docs/webhooks.md). These guides cover the tools that suit this form, with the relay code:

- [Receive form submissions on your own server](https://ikiform.com/blog/form-submissions-to-your-own-server.md)
- [Send form submissions to Slack](https://ikiform.com/blog/form-submissions-to-slack.md)
- [Send form submissions to Airtable](https://ikiform.com/blog/form-submissions-to-airtable.md)
- [Send form submissions to a Notion database](https://ikiform.com/blog/form-submissions-to-notion.md)

## Questions

### Can Ikiform receive file uploads?

Not yet. Posts that contain a file are refused with 422 files_not_supported. Collect a link, or upload to your own storage and submit the URL.

### Why not just send the file to Ikiform?

File uploads aren't built, and the whole request is limited to 256 KB. Uploading straight to object storage keeps large files off the form request entirely.

### Does an empty file input break the form?

No. An empty file input is ignored, so a form with an optional file field still submits when nothing is chosen.
