Skip to content
← All frameworks
jsx · React

JavaScript library

React form handling without a backend

A plain React SPA (Create React App, Vite, or a hand-rolled bundle) has no server of its own, which is usually the moment people reach for a third-party form backend. Formward is that backend: your component POSTs the form data to a single URL and gets back JSON, so you never stand up an Express server or a serverless function just to receive a contact form.

The component below is dependency-free - useState plus fetch. It captures the form with FormData, requests a JSON response, and tracks sending, success, and error so you can give the user real feedback instead of a full-page reload.

EU-hosted · A static React bundle plus an EU form endpoint means personal data is collected without ever touching infrastructure outside the EU/EEA.

The React example

Copy this into your project and replace <FORM_ID> with the id of a form you create in the Formward dashboard.

jsx

import { useState } from "react";

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

  async function onSubmit(e) {
    e.preventDefault();
    setStatus("sending");
    const form = e.target;

    try {
      const res = await fetch("https://forms.formward.eu/f/<FORM_ID>", {
        method: "POST",
        headers: { Accept: "application/json" },
        body: new FormData(form),
      });
      const data = await res.json(); // { ok: true, id, files }
      if (!res.ok || !data.ok) throw new Error(data.error || res.status);
      form.reset();
      setStatus("ok");
    } catch {
      setStatus("error");
    }
  }

  if (status === "ok") return <p>Thanks - your message is on its way.</p>;

  return (
    <form onSubmit={onSubmit}>
      <input type="email" name="email" required />
      <textarea name="message" required />
      <input type="text" name="_gotcha" tabIndex={-1} autoComplete="off"
        style={{ display: "none" }} aria-hidden="true" />
      <button disabled={status === "sending"}>
        {status === "sending" ? "Sending…" : "Send"}
      </button>
      {status === "error" && <p role="alert">Could not send. Try again.</p>}
    </form>
  );
}

Success and error handling

  • FormData reads every named field automatically, so adding inputs needs no extra JavaScript - just give each input a name attribute.
  • Branch on both res.ok and data.ok: a network failure throws, and a rejected submission returns { ok: false, error } with a 4xx status.
  • Because there is no server in the loop, there is nothing to deploy or patch - the only moving part is Formward's endpoint.

React 19: the same form with useActionState

React 19 lets a form's action be a function. useActionState gives you the pending flag and the last result, and React resets the form's uncontrolled inputs after the action finishes. The request is the same fetch as above; only the state handling moves into React.

jsx

import { useActionState } from "react";

const ENDPOINT = "https://forms.formward.eu/f/<FORM_ID>";

async function send(_prev, formData) {
  const res = await fetch(ENDPOINT, {
    method: "POST",
    headers: { Accept: "application/json" },
    body: formData,
  });
  const data = await res.json().catch(() => ({}));
  return res.ok && data.ok ? { ok: true } : { ok: false, status: res.status };
}

export function ContactForm() {
  const [state, action, pending] = useActionState(send, null);
  if (state?.ok) return <p>Thanks, your message is on its way.</p>;

  return (
    <form action={action}>
      <input type="email" name="email" required />
      <textarea name="message" required />
      <input type="text" name="_gotcha" tabIndex={-1} autoComplete="off"
        style={{ display: "none" }} aria-hidden="true" />
      <button disabled={pending}>{pending ? "Sending..." : "Send"}</button>
      {state?.status === 429 && <p role="alert">Too many attempts. Wait a minute and retry.</p>}
      {state && !state.ok && state.status !== 429 && <p role="alert">Could not send. Try again.</p>}
    </form>
  );
}

Spam protection

Every example above includes the _gotcha honeypot field. It is hidden from real users and must stay empty. When a bot fills it, Formward files the submission as spam and still answers with a normal success response, so the bot learns nothing and nothing reaches your inbox. Rate limits and the origin allowlist are checked on Formward's side as well. For a stricter gate, switch on Cloudflare Turnstile in the form's settings and add the widget; it adds a cf-turnstile-response field that Formward verifies on receipt. Turnstile is optional, and Cloudflare is a US provider, listed as such in the sub-processor register. The spam docs have the widget markup.

The JSON response

A standard POST gets a 302 redirect (or your thank-you page). Send the Accept: application/json header - as every fetch() example above does - and Formward returns JSON instead:

HTTP/1.1 200 OK
Content-Type: application/json

{ "ok": true, "id": "3f2a9c1e-...", "files": 0, "success": { "title": "Thank you!", ... } }

files is the number of uploaded files stored and success carries the confirmation text from the form's settings. When you set a redirect URL for the form, the reply also has a redirect field your code can send the visitor to. On failure the body is { ok: false, error } with a 4xx status: 422 when required consent is missing or a field is too long, 403 when a Turnstile check fails, 429 for too many submissions from one address, 402 when the account is over its plan limit. See responses and redirects for the full table and the AJAX docs for CORS.

Other frameworks

Collect your first React submission

Create a form, paste the snippet, and keep every submission in the EU. GDPR-clean from the first POST.

React contact form without a backend | Formward