Forms

8 fields

Job application form

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.

Form ID

Preview

Runs Ikiform's validator in your browser. Nothing is sent.

A public or shared link, for example Google Drive, Dropbox or your own site.

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.

<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>
  • 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 and Spam protection.

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 idTypeRequiredAccepts
full_nametextYesUp to 160 characters
emailemailYesOne valid address
phonephoneNo6 to 15 digits
roleselectYesOne of: frontend_engineer, backend_engineer, product_designer
cv_urlurlYesAn https: URL
portfolio_urlurlNoAn https: URL
cover_lettertextareaNoUp to 6,000 characters
consentcheckboxYesMust be checked, stored as true
Field definition
{
  "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
IKIFORM_SECRET_KEY=ik_sk_your_secret_key
app/job-application/actions.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
"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 for forms you send through a server.

Set it up

  1. Create a form in the 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 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

LimitValue
One field value16 KB
All stored fields together64 KB
The whole request256 KB
Long text field, maximum max_length16,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.

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 when the hiring round ends.

Where to send it

Every submission can go to a signed webhook. These guides cover the tools that suit this form, with the relay code:

Questions

Point your form at Ikiform