# Job application form: HTML, React and Next.js code

> A job application form that collects the role, a CV link, portfolio and cover letter, with consent, validated on the server before it reaches your hiring sheet.

Updated 2026-10-24 · https://ikiform.com/forms/job-application

## 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** (`job-application.html`)

```html
<form action="https://ikiform.com/f/frm_xxxxxxxxxxxxxxxx" method="post">
  <label for="full_name">Full name</label>
  <input id="full_name" name="full_name" type="text" maxlength="160" required />
  <label for="email">Email</label>
  <input id="email" name="email" type="email" autocomplete="email" required />
  <label for="phone">Phone</label>
  <input id="phone" name="phone" type="tel" autocomplete="tel" />
  <label for="role">Role</label>
  <select id="role" name="role" required>
    <option value="">Choose one</option>
    <option value="frontend_engineer">Frontend engineer</option>
    <option value="backend_engineer">Backend engineer</option>
    <option value="product_designer">Product designer</option>
  </select>
  <label for="cv_url">Link to your CV</label>
  <input id="cv_url" name="cv_url" type="url" required aria-describedby="cv_url-help" />
  <p id="cv_url-help">A public or shared link, for example Google Drive, Dropbox or your own site.</p>
  <label for="portfolio_url">Portfolio or GitHub</label>
  <input id="portfolio_url" name="portfolio_url" type="url" />
  <label for="cover_letter">Why this role?</label>
  <textarea id="cover_letter" name="cover_letter" rows="8" maxlength="6000"></textarea>
  <label><input name="consent" type="checkbox" required /> Keep my application on file for this hiring round</label>
  <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
  <button>Apply</button>
</form>
```

**React** (`JobApplicationForm.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 JobApplicationForm() {
  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 for applying. We reply to every application.</p>;

  return (
    <form onSubmit={onSubmit}>
      <label htmlFor="full_name">Full name</label>
      <input id="full_name" name="full_name" type="text" maxLength={160} required />
      {errors.full_name ? <p>{errors.full_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="phone">Phone</label>
      <input id="phone" name="phone" type="tel" autoComplete="tel" />
      {errors.phone ? <p>{errors.phone[0]}</p> : null}
      <label htmlFor="role">Role</label>
      <select id="role" name="role" required>
        <option value="">Choose one</option>
        <option value="frontend_engineer">Frontend engineer</option>
        <option value="backend_engineer">Backend engineer</option>
        <option value="product_designer">Product designer</option>
      </select>
      {errors.role ? <p>{errors.role[0]}</p> : null}
      <label htmlFor="cv_url">Link to your CV</label>
      <input id="cv_url" name="cv_url" type="url" required aria-describedby="cv_url-help" />
      <p id="cv_url-help">A public or shared link, for example Google Drive, Dropbox or your own site.</p>
      {errors.cv_url ? <p>{errors.cv_url[0]}</p> : null}
      <label htmlFor="portfolio_url">Portfolio or GitHub</label>
      <input id="portfolio_url" name="portfolio_url" type="url" />
      {errors.portfolio_url ? <p>{errors.portfolio_url[0]}</p> : null}
      <label htmlFor="cover_letter">Why this role?</label>
      <textarea id="cover_letter" name="cover_letter" rows={8} maxLength={6000} />
      {errors.cover_letter ? <p>{errors.cover_letter[0]}</p> : null}
      <label><input name="consent" type="checkbox" required /> Keep my application on file for this hiring round</label>
      {errors.consent ? <p>{errors.consent[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" : "Apply"}</button>
    </form>
  );
}
```

**JavaScript** (`job-application.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 = {
  full_name: "Full name",
  email: "Email",
  phone: "Phone",
  role: "Role",
  cv_url: "Link to your CV",
  portfolio_url: "Portfolio or GitHub",
  cover_letter: "Why this role?",
  consent: "Keep my application on file for this hiring round",
};

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 for applying. We reply to every application.");
      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 |
| --- | --- | --- | --- |
| `full_name` | `text` | Yes | Up to 160 characters |
| `email` | `email` | Yes | One valid address |
| `phone` | `phone` | No | 6 to 15 digits |
| `role` | `select` | Yes | One of: `frontend_engineer`, `backend_engineer`, `product_designer` |
| `cv_url` | `url` | Yes | An `https:` URL |
| `portfolio_url` | `url` | No | An `https:` URL |
| `cover_letter` | `textarea` | No | Up to 6,000 characters |
| `consent` | `checkbox` | Yes | Must be checked, stored as `true` |

`Field definition`:

```json
{
  "version": 1,
  "title": "Job application",
  "fields": [
    {
      "id": "full_name",
      "type": "text",
      "label": "Full name",
      "required": true,
      "validation": {
        "max_length": 160
      }
    },
    {
      "id": "email",
      "type": "email",
      "label": "Email",
      "required": true
    },
    {
      "id": "phone",
      "type": "phone",
      "label": "Phone"
    },
    {
      "id": "role",
      "type": "select",
      "label": "Role",
      "required": true,
      "options": [
        {
          "value": "frontend_engineer",
          "label": "Frontend engineer"
        },
        {
          "value": "backend_engineer",
          "label": "Backend engineer"
        },
        {
          "value": "product_designer",
          "label": "Product designer"
        }
      ]
    },
    {
      "id": "cv_url",
      "type": "url",
      "label": "Link to your CV",
      "required": true,
      "help_text": "A public or shared link, for example Google Drive, Dropbox or your own site.",
      "validation": {
        "https_only": true
      }
    },
    {
      "id": "portfolio_url",
      "type": "url",
      "label": "Portfolio or GitHub",
      "validation": {
        "https_only": true
      }
    },
    {
      "id": "cover_letter",
      "type": "textarea",
      "label": "Why this role?",
      "rows": 8,
      "validation": {
        "max_length": 6000
      }
    },
    {
      "id": "consent",
      "type": "checkbox",
      "label": "Keep my application on file for this hiring round",
      "required": true
    }
  ],
  "settings": {
    "submit_label": "Apply",
    "success_message": "Thanks for applying. We reply to every application."
  }
}
```

## 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/job-application/actions.ts`:

```ts
"use server";

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

export async function sendJobApplicationForm(_previous: JobApplicationFormState, formData: FormData): Promise<JobApplicationFormState> {
  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 for applying. We reply to every application." };
  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/job-application/form.tsx`:

```tsx
"use client";

import { useActionState } from "react";
import { type JobApplicationFormState, sendJobApplicationForm } from "./actions";

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

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

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

  return (
    <form action={action}>
      <label htmlFor="full_name">Full name</label>
      <input id="full_name" name="full_name" type="text" maxLength={160} required defaultValue={state.values?.full_name?.[0]} />
      {state.fields?.full_name ? <p>{state.fields.full_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="phone">Phone</label>
      <input id="phone" name="phone" type="tel" autoComplete="tel" defaultValue={state.values?.phone?.[0]} />
      {state.fields?.phone ? <p>{state.fields.phone[0]}</p> : null}
      <label htmlFor="role">Role</label>
      <select id="role" name="role" required defaultValue={state.values?.role?.[0]}>
        <option value="">Choose one</option>
        <option value="frontend_engineer">Frontend engineer</option>
        <option value="backend_engineer">Backend engineer</option>
        <option value="product_designer">Product designer</option>
      </select>
      {state.fields?.role ? <p>{state.fields.role[0]}</p> : null}
      <label htmlFor="cv_url">Link to your CV</label>
      <input id="cv_url" name="cv_url" type="url" required aria-describedby="cv_url-help" defaultValue={state.values?.cv_url?.[0]} />
      <p id="cv_url-help">A public or shared link, for example Google Drive, Dropbox or your own site.</p>
      {state.fields?.cv_url ? <p>{state.fields.cv_url[0]}</p> : null}
      <label htmlFor="portfolio_url">Portfolio or GitHub</label>
      <input id="portfolio_url" name="portfolio_url" type="url" defaultValue={state.values?.portfolio_url?.[0]} />
      {state.fields?.portfolio_url ? <p>{state.fields.portfolio_url[0]}</p> : null}
      <label htmlFor="cover_letter">Why this role?</label>
      <textarea id="cover_letter" name="cover_letter" rows={8} maxLength={6000} defaultValue={state.values?.cover_letter?.[0]} />
      {state.fields?.cover_letter ? <p>{state.fields.cover_letter[0]}</p> : null}
      <label><input name="consent" type="checkbox" required defaultChecked={Boolean(state.values?.consent)} /> Keep my application on file for this hiring round</label>
      {state.fields?.consent ? <p>{state.fields.consent[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" : "Apply"}</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 **Strict, 2 every 10 minutes per visitor** if visitors post from the browser. One person applies once. Strict still leaves room to fix a typo and send again. 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.

## CVs as links, not files

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. Ask for a link instead, as this form does, or upload the CV to your own storage first and submit its URL. The [file upload form](https://ikiform.com/forms/file-upload) shows how.

`https_only` on the URL fields refuses `http:` links, and the stored value is the normalized URL, with `https://` added when the applicant leaves it out.

## Size limits that matter

| Limit | Value |
| --- | --- |
| One field value | 16 KB |
| All stored fields together | 64 KB |
| The whole request | 256 KB |
| Long text field, maximum `max_length` | 16,000 characters |

The value limit is in bytes, not characters, so a 16,000 character cover letter in a script that uses several bytes per character can pass `max_length` and still hit the 16 KB limit with `413 payload_too_large`. The 6,000 character cap here stays clear of it.

## Links and the spam score

This form can already send two links, `cv_url` and `portfolio_url`. Three or more links across all fields add 2 to the spam score, so when both are filled in, one more link in the cover letter is enough. With an identical repeat (+3) that reaches the threshold of 5, and the application is stored as spam without an error. Spam sends no webhooks or emails, so review spam submissions in the dashboard. Marking one as not spam sends its webhooks then.

## Personal data

- The required `consent` checkbox is stored as `true`, next to the submission's timestamp, so you have a record of what each applicant agreed to.
- Don't ask for passwords, card numbers or national ID numbers. Field names that look like credentials, such as `ssn`, are flagged and their notification emails held for review.
- Delete an application from the dashboard or with `DELETE /submissions/:id` in the [REST API](https://ikiform.com/docs/api.md) when the hiring round ends.

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

- [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)
- [Send form submissions to Google Sheets](https://ikiform.com/blog/form-submissions-to-google-sheets.md)
- [Send form submissions to Slack](https://ikiform.com/blog/form-submissions-to-slack.md)

## Questions

### Can applicants upload a CV file?

Not directly. Ikiform refuses posts with files (422 files_not_supported). Ask for a link, or upload the file to your own storage such as S3 or R2 and submit the resulting URL in a url field.

### How long can a cover letter be?

A long text field allows up to 16,000 characters, but every field value is also limited to 16 KB. This form caps the cover letter at 6,000 characters to stay well inside both.

### Where should applications go?

Somewhere the hiring team can review them side by side: an Airtable base, a Notion database or a Google Sheet, fed by a signed webhook, with a Slack message per application if you want a heads-up.
