Skip to content
form koi.Start free

Vanilla JavaScript contact form

JavaScript contact form
with Fetch. No framework.

Submit a plain HTML form with Fetch and show inline loading, success and error messages. Copy the vanilla JavaScript example; Form Koi handles submission storage and email notifications.

250 submissions/month free. No credit card required.

HTML plus one submit handler.

Paste this into your contact page. Replace the complete placeholder endpoint, verify your recipient and configure botcheck as the honeypot. Serve the page over HTTPS (or localhost) so crypto.randomUUID is available. This example has no attachments or Turnstile widget.

contact.html — HTML and vanilla JavaScript
<form id="contact-form" action="https://api.formkoi.com/f/YOUR_PUBLIC_FORM_KEY" method="POST">
  <label for="name">Your name</label>
  <input id="name" name="name" autocomplete="name" required>

  <label for="email">Email address</label>
  <input id="email" name="email" type="email" autocomplete="email" required>

  <label for="message">Your message</label>
  <textarea id="message" name="message" required></textarea>

  <!-- Leave this hidden anti-spam field empty. -->
  <input type="text" name="botcheck"
    tabindex="-1" autocomplete="off" style="display:none" hidden>

  <button type="submit">Send message</button>
</form>
<p id="form-status" role="status" aria-live="polite"></p>
<script>
  const form = document.querySelector("#contact-form");
  const status = document.querySelector("#form-status");
  const button = form.querySelector('button[type="submit"]');
  let busy = false;
  let previous = null;

  form.addEventListener("submit", async (event) => {
    event.preventDefault();
    if (busy) return;
    const body = new FormData(form);
    const fingerprint = JSON.stringify([...body.entries()]);
    if (!previous || previous.fingerprint !== fingerprint) {
      previous = { fingerprint, key: crypto.randomUUID() };
    }
    busy = true;
    button.disabled = true;
    status.textContent = "Sending…";
    const controller = new AbortController();
    const timeout = setTimeout(() => controller.abort(), 15000);
    try {
      const response = await fetch(form.action, {
        method: "POST",
        headers: {
          "Accept": "application/json",
          "Idempotency-Key": previous.key
        },
        body,
        credentials: "omit",
        signal: controller.signal
      });
      if (!response.ok) {
        status.textContent = response.status === 429
          ? "The form has reached a limit. Please try later or contact us another way."
          : "The submission was not accepted. Please check your details or try later.";
        return;
      }
      status.textContent = "Your submission was received. Thank you!";
      // Keep any edits made while the request was in flight.
      if (JSON.stringify([...new FormData(form).entries()]) === fingerprint) {
        form.reset();
      }
      previous = null;
    } catch {
      status.textContent = "We could not confirm receipt. Your details are still here; retrying unchanged details reuses the same request ID.";
    } finally {
      clearTimeout(timeout);
      busy = false;
      button.disabled = false;
    }
  });
</script>

The browser sets the multipart Content-Type and boundary. The Accept header requests JSON rather than a native form redirect. Keep management API tokens out of browser code.

Give failed requests a useful next step.

01

Keep the visitor’s words

Failed requests leave the form intact. A timeout says receipt is uncertain because the server might already have accepted the message.

02

Reuse the request ID

An unchanged retry reuses its idempotency key within this page session. Editing the form creates a new request. Do not automatically retry quota errors.

Explore
03

Test your deployed origin

Allow the exact website origin in form settings. If you enable Turnstile, add its widget and submit a fresh token using your workspace connection guide.

Explore

Frequently asked questions

Contact form
questions.

Does JavaScript itself send the email?

No. Fetch sends the form fields to Form Koi. The service stores the submission and queues configured notifications. A successful HTTP response does not confirm email delivery.

Does the form work without JavaScript?

Yes. Its action and method still allow a native browser POST. Configure a thank-you destination for that path; the inline status requires JavaScript.

Can I use this in React?

Use the dedicated React example for component state and lifecycle handling. This snippet attaches a listener to a plain HTML page and should be initialized once per form.

More ways to connect.

Explore the React component, form-to-email guide or styled HTML templates.

Start with Form Koi Free

Start receiving
form submissions.

Create a free form Read the quickstart