SheetLink Forms beta

AJAX form submission, from scratch and with sl.js

First the hand-rolled version, so you know exactly what is happening. Then the one-line version, so you know what you are trading.

Why submit with AJAX at all

A plain HTML form works without any JavaScript: the browser posts, the endpoint answers with a 303, and the visitor lands on a thank-you page. That default is underrated, and if your form is a dedicated contact page, keep it - see redirects and thank-you pages for making the most of it.

AJAX earns its complexity when navigating away is wrong: a newsletter box in a footer, a form inside a modal, a multi-step UI, anything mid-task where a full page load would discard state. The deal is: you take over the submit, you get full control of the success and error experience, and you inherit the responsibility for both. This tutorial builds that from scratch against a SheetLink endpoint, then shows the packaged version.

From scratch: the minimal correct version

Start with markup that still works if JavaScript never loads - the action stays in place as a fallback, and the honeypot stays in every form you ship:

<form id="contact" action="https://sheetlinkforms.com/f/slf_yourtoken" method="POST">
  <input name="email" type="email" placeholder="Work email" required>
  <textarea name="message" placeholder="Message"></textarea>
  <input name="_slhp" tabindex="-1" autocomplete="off" style="position:absolute;left:-9999px">
  <button type="submit">Send</button>
  <p id="status" role="status"></p>
</form>

Then the script:

const form = document.getElementById("contact");
form.addEventListener("submit", async (e) => {
  e.preventDefault();
  const body = new URLSearchParams(new FormData(form));
  const res = await fetch(form.action, {
    method: "POST",
    headers: { "Accept": "application/json" },
    body
  });
  const data = await res.json(); // { "ok": true, "id": "..." }
  form.querySelector("#status").textContent = "Thanks - message sent.";
  form.reset();
});

Two deliberate choices. URLSearchParams makes the body application/x-www-form-urlencoded, which keeps this a CORS "simple request" - one HTTP round trip, no preflight (the full taxonomy is in CORS for form endpoints). And Accept: application/json tells the endpoint to answer with JSON instead of the 303 redirect a browser form would get.

Handling failure like you mean it

The minimal version above has the usual tutorial flaw: it assumes success. Production needs three additions - disable the button during flight, catch both network failures and HTTP errors, and always restore the UI:

form.addEventListener("submit", async (e) => {
  e.preventDefault();
  const btn = form.querySelector("button");
  const status = form.querySelector("#status");
  btn.disabled = true;
  status.textContent = "Sending...";
  try {
    const res = await fetch(form.action, {
      method: "POST",
      headers: { "Accept": "application/json" },
      body: new URLSearchParams(new FormData(form))
    });
    if (!res.ok) throw new Error("HTTP " + res.status);
    status.textContent = "Thanks - message sent.";
    form.reset();
  } catch (err) {
    status.textContent = "Something went wrong - please try again.";
  } finally {
    btn.disabled = false;
  }
});

The statuses you might meet: 404 means a mistyped token, 403 the origin allowlist or a failed Turnstile check, 413 a body over 256 KB, 429 rate limiting, 410 a paused form. A disabled submit button is also your cheap double-submit guard - without it, an impatient double click means two rows.

The JSON variant

If your data is already an object - common in SPAs - you can post JSON instead:

await fetch("https://sheetlinkforms.com/f/slf_yourtoken", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ email, message, _slhp: "" })
});

Functionally identical - fields map to columns the same way, names matching headers automatically. The one cost: application/json is not a CORS-safelisted content type, so the browser sends an OPTIONS preflight before the POST. The endpoint answers preflights correctly, so it works; it is simply two round trips where urlencoded takes one. Choose JSON for ergonomics in app code, urlencoded for plain pages.

The same thing with sl.js

Everything above, packaged, is one script tag:

<script src="https://sheetlinkforms.com/sl.js" data-token="slf_yourtoken"></script>

<form data-sheetlink data-success-message="Thanks - we will be in touch.">
  <input name="email" type="email" placeholder="Work email" required>
  <input name="_slhp" tabindex="-1" autocomplete="off" style="position:absolute;left:-9999px">
  <button type="submit">Subscribe</button>
</form>

The embed binds forms marked data-sheetlink and any form whose action points at /f/, converts submits to AJAX with inline success text (data-success-message to customize, data-redirect to navigate on success instead), and injects the honeypot if missing. It also does things the hand-rolled version does not: a timing signal that strengthens spam screening, capture of utm_* parameters and ad click IDs persisted across pages, and MutationObserver binding for forms rendered late in SPAs. When you need to hook in - clearing app state, firing an analytics event - sheetlink:success and sheetlink:error bubble from the form with details in event.detail. Full reference in the docs.

Choosing between them

An honest split:

  1. Hand-roll when the submit is part of a larger interaction you own end to end - multi-step flows, optimistic UI, custom validation choreography - and you would be fighting any wrapper. The from-scratch version above is complete; the endpoint treats it identically.
  2. Use sl.js when the form is a form. You skip writing and maintaining the fetch scaffolding, and you gain the pieces you would probably never build: cross-page attribution and the timing signal. On marketing sites this is almost always the right column.
  3. Mixing is fine. The embed and hand-rolled fetches can coexist on one site - the endpoint does not care which shape arrives, and both pass through the same screening, quarantine, mapping, and delivery pipeline described on how it works.

Try either style against a live endpoint on the demo, and grab a beta invite from the waitlist - free during beta, no per-submission fees ever.

FAQ

Why use URLSearchParams instead of sending FormData directly?

Both work. URLSearchParams produces an urlencoded body, which is compact and CORS-preflight-free. Raw FormData sends multipart, which is also preflight-free but heavier - and file parts are currently dropped by the endpoint anyway, so urlencoded gives up nothing for text forms.

How do I get JSON back instead of a redirect?

Send an Accept: application/json header (or make the request body JSON). The endpoint then answers 200 with {"ok":true,"id":"..."} instead of the 303 redirect reserved for plain browser posts.

Should I keep the action attribute if JavaScript handles the submit?

Yes. With action in place, the form still works when your script fails to load or errors - the browser falls back to a normal POST and the visitor lands on the thank-you page. Progressive enhancement costs one attribute; a form that only works with JavaScript fails silently without it.

Does the hand-rolled version still get spam protection?

Yes - screening happens at the endpoint, not in the browser. Keep the _slhp honeypot in your markup and send it (empty) with your payload. What you lose without sl.js is the timing signal, one input among several; rate limits, heuristics, and quarantine apply regardless.

How do I run code after a successful sl.js submission?

Listen for the sheetlink:success event on or above the form - it bubbles, with details in event.detail. sheetlink:error is its counterpart for failures. This is the intended seam for analytics events, modal closing, or state resets in SPAs.

Related guides: CORS for form endpoints: what actually needs it · Redirects and thank-you pages after form submit · Send an HTML form to Google Sheets without a backend

Request an invite Developer docs