Skip to content
FormSubmit
Engineering

Jamstack Forms: Handling Submissions on Static Sites

How to build jamstack forms that work everywhere: architecture options, progressive enhancement with fetch, env config, preview deployments and testing.

FormSubmit Team

6 min read

Architecture diagram of jamstack forms posting from a static site to a form backend endpoint

Jamstack forms work by posting a plain HTML form to an external endpoint, either a serverless function you maintain or a hosted form backend, because a static site has no server of its own to receive the data. The most robust pattern is progressive enhancement: a normal form that works without JavaScript, upgraded with fetch for inline success and error messages.

Static site generators and frameworks like Next.js, Astro, Gatsby, Eleventy and Hugo produce fast, cacheable pages. The trade-off is that the moment you need a contact form, signup or quote request, you need somewhere for the POST to go. This guide compares the options and gives you code you can drop into most projects.

Architecture options for jamstack forms

There are three common ways to handle form submissions on a static site.

ApproachYou buildYou maintainBest for
Serverless functionEndpoint, validation, email, spam checks, storageEmail deliverability, secrets, logs, retriesCustom workflows, existing backend
Host-specific form featureLittleLock-in to that hostSites that will never move hosts
Hosted form backendNothing server-sideEndpoint URL and settingsContact, lead, signup, upload forms

Serverless forms you write yourself

A function on your host receives the POST, validates it, and sends an email or writes to a database. It is flexible, but the endpoint is the easy part. You also need an email provider with proper DNS records, spam filtering, rate limiting, a place to read submissions, file storage if you accept uploads, and retry logic for when your email provider is briefly down.

A hosted form backend

A headless form backend gives you a URL. Your form posts there, and the service handles storage, spam filtering, email notifications and integrations. Because it is just a URL, the same approach works across every framework and survives moving from one host to another. FormSubmit is one example, and its endpoints look like https://formsubmit.app/f/YOUR_FORM_ID.

The rest of this guide assumes a hosted endpoint, but the front-end patterns apply equally to your own serverless function.

Start with progressive enhancement

Progressive enhancement means the form works as plain HTML first. If JavaScript fails to load, is blocked, or the visitor is on a flaky connection, the browser still submits the form natively.

html
<form
  id="contact"
  action="https://formsubmit.app/f/YOUR_FORM_ID"
  method="POST"
>
  <label for="email">Email</label>
  <input id="email" name="email" type="email" required>

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

  <input type="text" name="_gotcha" tabindex="-1" autocomplete="off" style="display:none">
  <input type="hidden" name="_redirect" value="https://example.com/thanks">

  <button type="submit">Send</button>
  <p id="status" role="status" aria-live="polite"></p>
</form>

Without JavaScript, the browser POSTs the form and the backend redirects to your thank-you page.

Add the fetch enhancement

With JavaScript available, intercept the submit, send the data with fetch, and show the result inline. Asking for JSON with the Accept header means the backend returns a JSON response instead of a redirect.

js
const form = document.getElementById("contact");
const status = document.getElementById("status");

form.addEventListener("submit", async (event) => {
  event.preventDefault();
  const button = form.querySelector("button");
  button.disabled = true;
  status.textContent = "Sending...";

  try {
    const res = await fetch(form.action, {
      method: "POST",
      body: new FormData(form),
      headers: { Accept: "application/json" },
    });
    const data = await res.json();

    if (data.ok) {
      form.reset();
      status.textContent = "Thanks! We will get back to you soon.";
    } else {
      status.textContent = data.error?.message ?? "Something went wrong.";
    }
  } catch {
    status.textContent = "Network error. Please try again.";
  } finally {
    button.disabled = false;
  }
});

On success you get { "ok": true, "id": "..." }. On failure you get { "ok": false, "error": { "code": "...", "message": "..." } }, so you can branch on the code (for example rate_limited or domain_not_allowed) while showing the human-readable message.

Two small additions improve spam filtering. Add a hidden _ts field set to Date.now() when the page loads, so the backend can flag submissions sent faster than a human could type. And disable the button while sending to avoid double submissions.

Forms for Next.js, Astro and Gatsby

The pattern is the same in every framework: a form element, a submit handler, and an endpoint from configuration.

tsx
"use client";
import { useState } from "react";

const ENDPOINT = process.env.NEXT_PUBLIC_FORM_ENDPOINT!;

export function ContactForm() {
  const [status, setStatus] = useState<"idle" | "sending" | "sent" | "error">("idle");

  async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault();
    setStatus("sending");
    const res = await fetch(ENDPOINT, {
      method: "POST",
      body: new FormData(e.currentTarget),
      headers: { Accept: "application/json" },
    });
    const data = await res.json();
    setStatus(data.ok ? "sent" : "error");
  }

  if (status === "sent") return <p role="status">Thanks, message received.</p>;

  return (
    <form action={ENDPOINT} method="POST" onSubmit={onSubmit}>
      <input name="email" type="email" required />
      <textarea name="message" required />
      <input type="text" name="_gotcha" tabIndex={-1} autoComplete="off" style={{ display: "none" }} />
      <button disabled={status === "sending"}>Send</button>
      {status === "error" && <p role="alert">Could not send. Please try again.</p>}
    </form>
  );
}

Keeping action and method on the element preserves the no-JavaScript fallback for server-rendered output. In Astro, put the HTML form in a .astro component and the enhancement script in a <script> tag. In Gatsby, the React component above works almost unchanged. Framework walkthroughs are in the Next.js and React guides.

Environment config

Do not hard-code the endpoint across components. Put it in an environment variable using your framework's public prefix:

  • Next.js: NEXT_PUBLIC_FORM_ENDPOINT
  • Astro and Vite-based tools: PUBLIC_FORM_ENDPOINT
  • Gatsby: GATSBY_FORM_ENDPOINT

A form endpoint is not a secret, since it appears in the HTML anyway, but keeping it in config lets each environment use a different form. Never put webhook signing secrets or captcha secret keys in public variables. Those belong on a server or in your form backend's settings.

Preview deployments and the domain allowlist

Preview deployments are a strength of the Jamstack workflow, and a common source of "my form works locally but not in preview" bugs.

  • Use a separate test form for previews. Point preview and development builds at a test endpoint so test messages never mix with real leads or trigger client notifications.
  • Plan your domain allowlist. An allowlist restricts which sites can submit to a form, which stops other people from reusing your endpoint. In FormSubmit, example.com also matches www.example.com, *.example.com covers subdomains, and localhost works for local testing. See domain allowlist.
  • Be careful with wildcard platform domains. Allowing every subdomain of a shared hosting domain also allows other people's sites. Prefer specific preview hostnames or a custom preview subdomain you control.

Testing your form handling

  1. Submit with JavaScript disabled and confirm the redirect to your thank-you page.
  2. Submit with JavaScript enabled and confirm the inline success message.
  3. Trigger an error, for example by submitting from a domain not on the allowlist, and check the message shown.
  4. Test on a preview deployment using the test endpoint.
  5. Confirm the submission appears in your dashboard, email and any integrations.
  6. Run a quick end-to-end test in Playwright or Cypress that fills and submits the form against the test endpoint.

For downstream automation, a form webhook lets you push submissions into your own systems with signed requests.

Wrapping up

Jamstack forms do not need a server of your own. Start with a real HTML form, enhance it with fetch, keep the endpoint in config, and treat previews as their own environment. If you would rather not maintain serverless form handling yourself, FormSubmit gives you an endpoint with spam protection, email notifications and webhooks on every plan. Generate a form in the form generator or see pricing.

Last updated .

Frequently asked questions

How do forms work on a Jamstack site?

The static HTML form posts to an external endpoint, either a serverless function you write or a hosted form backend. That endpoint validates, stores and forwards the submission.

Do Jamstack forms need JavaScript?

No. A standard HTML form with an action URL and method POST works without JavaScript. You can then add fetch-based submission as an enhancement for a smoother experience.

Should I write a serverless function or use a form backend?

A serverless function gives full control but means you own email delivery, spam filtering, storage and retries. A form backend handles those for you, which is usually faster for contact, signup and lead forms.

How do I handle forms on preview deployments?

Use an environment variable for the endpoint so previews can point at a separate test form, and make sure your domain allowlist covers the preview domains you actually use.

Related resources

Keep reading

Your form backend is 60 seconds away

Sign up with Google or email, create a form, paste the endpoint. Free forever for small sites — no credit card.