Skip to content
← All frameworks
tsx · SolidJS

JavaScript framework

SolidJS form handling without a backend

A Solid app built with Vite is static files once it is built, so a contact form has nowhere to send its data. Formward is that place: the component posts the form to one URL, and the submission is spam-filtered, stored in Sweden and emailed to you. There is no server function to write.

The component below uses Solid's own primitives. A signal holds the status, Show swaps the form for a thank-you line, and the submit handler sends FormData with fetch. The form also carries an action, so it still posts if the JavaScript has not loaded.

EU-hosted · A Solid build can sit on any static host or CDN. The names, emails and messages it collects are received and stored by Formward in Sweden.

The SolidJS example

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

tsx

// src/ContactForm.tsx
import { createSignal, Show } from "solid-js";

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

type Status = "idle" | "sending" | "ok" | "error";

export function ContactForm() {
  const [status, setStatus] = createSignal<Status>("idle");
  const [error, setError] = createSignal("");

  async function onSubmit(e: SubmitEvent & { currentTarget: HTMLFormElement }) {
    e.preventDefault();
    const form = e.currentTarget;
    setStatus("sending");

    try {
      const res = await fetch(ENDPOINT, {
        method: "POST",
        headers: { Accept: "application/json" },
        body: new FormData(form),
      });
      const data = await res.json(); // { ok: true, id, files } or { ok: false, error }
      if (!res.ok || !data.ok) {
        setError(res.status === 429
          ? "Too many attempts. Wait a minute and try again."
          : "Could not send. Please try again.");
        setStatus("error");
        return;
      }
      form.reset();
      setStatus("ok");
    } catch {
      setError("Could not send. Check your connection and try again.");
      setStatus("error");
    }
  }

  return (
    <Show when={status() !== "ok"} fallback={<p>Thanks, we will reply by email.</p>}>
      <form action={ENDPOINT} method="post" onSubmit={onSubmit}>
        <input name="name" placeholder="Your name" required />
        <input type="email" name="email" placeholder="you@example.com" required />
        <textarea name="message" placeholder="Your message" required />
        {/* Honeypot: real users never see or fill this. */}
        <input type="text" name="_gotcha" tabIndex={-1} autocomplete="off"
          style={{ position: "absolute", left: "-5000px" }} aria-hidden="true" />
        <button type="submit" disabled={status() === "sending"}>
          {status() === "sending" ? "Sending..." : "Send"}
        </button>
        <Show when={status() === "error"}>
          <p role="alert">{error()}</p>
        </Show>
      </form>
    </Show>
  );
}

Success and error handling

  • Solid runs the component function once. Only the parts that read status() or error() update when the signals change, so the inputs keep what the visitor typed while the request is in flight.
  • The Accept: application/json header makes Formward answer with JSON instead of its 302 redirect, so the visitor stays on the page. Check both res.ok and data.ok: a rejected submission comes back as { ok: false, error } with a 4xx status.
  • The statuses to handle are 422 for a failed field rule (the body names the field), 429 when one address sends too fast, 402 when the account is over its plan limit and 403 for an origin outside the form's allowlist.
  • Because the form has an action and a method, it falls back to a normal POST if the script never runs. Formward then answers with a 302 to its thank-you page, or to a path you put in a hidden _redirect field.

Render it

Mount the component like any other. In a plain Vite app that is render in src/index.tsx; in a SolidStart route, return it from the route component. The submit still runs in the browser and posts straight to Formward, so SolidStart needs no server function for it and the route can be prerendered.

To keep the endpoint out of the source, read it from an env file instead: import.meta.env.VITE_FORMWARD_ENDPOINT. The Vite page linked below shows the env file and its type declaration.

tsx

// src/index.tsx
import { render } from "solid-js/web";
import { ContactForm } from "./ContactForm";

render(() => (
  <main>
    <h1>Get in touch</h1>
    <ContactForm />
  </main>
), document.getElementById("root")!);

Pointing at the field that failed, with createStore

When a field rule in the dashboard rejects a value, Formward answers 422 and names the field, for example { ok: false, error: "field_invalid", field: "email" }. A store keeps the status and the failing field together, and the inputs read from it to set aria-invalid.

Passing an object to the store's setter merges it into the current state, so one call updates both values.

tsx

import { Show } from "solid-js";
import { createStore } from "solid-js/store";

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

type State = { status: "idle" | "sending" | "ok" | "error"; field: string | null };

export function ContactForm() {
  const [state, setState] = createStore<State>({ status: "idle", field: null });

  async function onSubmit(e: SubmitEvent & { currentTarget: HTMLFormElement }) {
    e.preventDefault();
    const form = e.currentTarget;
    setState({ status: "sending", field: null });
    try {
      const res = await fetch(ENDPOINT, {
        method: "POST",
        headers: { Accept: "application/json" },
        body: new FormData(form),
      });
      const data = await res.json();
      if (res.ok && data.ok) {
        form.reset();
        setState("status", "ok");
        return;
      }
      setState({ status: "error", field: res.status === 422 ? data.field ?? null : null });
    } catch {
      setState({ status: "error", field: null });
    }
  }

  return (
    <Show when={state.status !== "ok"} fallback={<p>Thanks, we will reply by email.</p>}>
      <form action={ENDPOINT} method="post" onSubmit={onSubmit}>
        <input type="email" name="email" required aria-invalid={state.field === "email"} />
        <textarea name="message" required aria-invalid={state.field === "message"} />
        <input type="text" name="_gotcha" tabIndex={-1} autocomplete="off"
          style={{ position: "absolute", left: "-5000px" }} aria-hidden="true" />
        <button type="submit" disabled={state.status === "sending"}>Send</button>
        <Show when={state.status === "error"}>
          <p role="alert">
            {state.field ? "Check the " + state.field + " field." : "Could not send. Please try again."}
          </p>
        </Show>
      </form>
    </Show>
  );
}

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 SolidJS submission

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

SolidJS contact form without a backend | Formward