A Drop-In JavaScript Form Helper for Any Static Site

One script tag gives your HTML form a complete backend: AJAX submission, inline validation errors, CAPTCHA, and lifecycle events. No build step, no framework lock-in.

StaticForm Team
On this page

Adding a working contact form to a static site used to mean standing up a backend, wiring a CAPTCHA provider, and writing the same pile of submit glue code on every project: a fetch handler, loading state, validation that maps server errors back to the right input, a success message, a network-error fallback. Nobody enjoys writing it twice.

You don’t actually need any of it to use StaticForm. A plain HTML <form action="…" method="POST"> works fine. The backend accepts it, validates it, and shows a hosted thank-you page. But for most sites you want to keep the user on the page, show errors next to the offending field, and add CAPTCHA without wiring a provider by hand.

That’s what our drop-in helper is for. It’s a tiny (~2 KB gzipped), framework-agnostic script you add to any page. One script tag, one function call, and you get the polished submit experience without writing submit code yourself.

Here’s how it works.

What the helper does for you

  • A submit handler that POSTs FormData to your StaticForm endpoint
  • Field-level error rendering from the API’s ProblemDetails response
  • CAPTCHA integration for reCAPTCHA v3, reCAPTCHA v2, Cloudflare Turnstile, and hCaptcha. The provider’s loader script is injected for you.
  • Focus, blur, and input styling so fields visually reset as the user types
  • Custom events (sf:submitting, sf:success, sf:error, sf:settled) for loading spinners and thank-you messages
  • A network-error fallback so a dropped connection doesn’t look like a silent failure

No build step. No dependencies. Works with plain HTML, Astro, Eleventy, Hugo, Jekyll, or anything else that outputs static pages.

Step 1: Include the script

Drop it in before the closing </body> tag:

<script src="https://staticform.app/scripts/staticform.js" defer></script>

Point at the copy we host, or download it and serve it yourself.

Step 2: Write plain HTML

No special components. Just a regular form with name attributes, plus one error placeholder per field:

<form id="contact-form">
  <label>
    Email
    <input name="email" type="email" required>
  </label>
  <p data-sf-error="email"></p>

  <label>
    Message
    <textarea name="message" required></textarea>
  </label>
  <p data-sf-error="message"></p>

  <p data-sf-general-error></p>

  <button type="submit">Send</button>
</form>

Two conventions to remember:

  • data-sf-error="fieldname" marks the element that will receive the error text for a given field.
  • data-sf-general-error marks the element that will receive any error not tied to a specific field (network issues, rate limits, CAPTCHA failures, etc.).

Both start empty and are only shown if there’s something to say.

Step 3: Attach the handler

Grab a form endpoint from your StaticForm dashboard and call attach:

<script>
  StaticForm.attach(document.getElementById('contact-form'), {
    endpoint: 'https://api.staticform.app/api/v1/forms/YOUR_FORM_ID',
  });
</script>

That’s it. Submit the form. The helper will POST it, render any validation errors next to the right fields, and reset the form on success.

Adding CAPTCHA

One extra config block. The helper injects the provider’s loader script automatically if it’s not already on the page:

<script>
  StaticForm.attach(document.getElementById('contact-form'), {
    endpoint: 'https://api.staticform.app/api/v1/forms/YOUR_FORM_ID',
    captcha: {
      type: 'turnstile',          // or 'recaptcha-v3', 'recaptcha-v2', 'hcaptcha'
      siteKey: 'YOUR_SITE_KEY',   // required for recaptcha-v3
    },
  });
</script>

For widget-based providers (Turnstile, reCAPTCHA v2, hCaptcha) you render the widget in your markup as usual; the helper reads the token at submit time and resets the widget after each submission. For reCAPTCHA v3 the token is generated programmatically.

If you load the provider script yourself (for example with a custom CSP nonce), pass loadScript: false inside captcha to opt out of auto-injection.

Hooking into events

For a thank-you message, a loading spinner, or analytics, listen to the custom events the helper dispatches on the form:

<script>
  const form = document.getElementById('contact-form');

  form.addEventListener('sf:submitting', () => {
    form.querySelector('button').disabled = true;
  });

  form.addEventListener('sf:success', () => {
    document.getElementById('thanks').hidden = false;
  });

  form.addEventListener('sf:settled', () => {
    form.querySelector('button').disabled = false;
  });

  StaticForm.attach(form, {
    endpoint: 'https://api.staticform.app/api/v1/forms/YOUR_FORM_ID',
  });
</script>

sf:submitting fires when the submit handler runs. sf:success fires on a 2xx response with the parsed body in event.detail. sf:error fires on any failure with the error array in event.detail. sf:settled fires at the end of every submit, success or failure, which makes it the right hook for re-enabling the button.

Prefer callbacks? Pass onSuccess and onError to attach instead; they receive the same payloads.

Customizing the look

The helper applies subtle focus, blur, and error border styles so fields look alive as the user interacts. Every color and border is overridable:

StaticForm.attach(form, {
  endpoint: '…',
  styles: {
    errorColor:          '#dc2626',
    focusColor:          'rgba(59,130,246,0.5)',
    focusShadow:         '0 0 0 3px rgba(59,130,246,0.15)',
    defaultBorderStyle:  '1px solid #e5e7eb',
    errorBorderStyle:    '1px solid #dc2626',
  },
});

Or leave styles out entirely and write your own CSS against the fields and error elements. The helper won’t fight you.

That’s the whole thing

One script tag, one call to StaticForm.attach, and you’ve skipped the entire stack of form glue code most sites end up writing from scratch. No framework lock-in, no build pipeline, no server to run. If you ever outgrow the helper, the endpoint still accepts a plain HTML POST, so nothing locks you in.

If you haven’t yet, create a form in the dashboard, copy the endpoint, and paste it in. You’ll have a working contact form before your coffee cools off.

For the full option reference, including every config key, event payload, and CAPTCHA nuance, see the JavaScript helper docs.

Using a framework?

The helper works everywhere, but if you want framework-specific examples (mounting on client-side navigation, handling hydration, or using native form primitives), we have dedicated walkthroughs:

Evaluating other form backends? See how StaticForm compares to the alternatives, including a hands-on breakdown of StaticForms.dev.