Skip to content
← All frameworks
tsx · Qwik

JavaScript framework

Qwik form handling without a backend

Qwik renders on the server and sends almost no JavaScript until a visitor interacts. A contact form fits that well when it posts straight to Formward: the page stays light, the handler is only downloaded when someone submits, and the submission is spam-filtered, stored in Sweden and emailed to you.

The first example is a component that posts with fetch from the browser. The second keeps the form inside Qwik City with routeAction$ and forwards the fields from your server, which is only worth it when your server has to see the data first. The code uses Qwik 1 imports; the notes cover Qwik 2.

EU-hosted · Your Qwik site can be served from an edge network or a static host while the contact data it collects is received and stored by Formward in Sweden.

The Qwik example

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

tsx

// src/components/contact-form/contact-form.tsx
import { component$, useSignal, $ } from "@builder.io/qwik";

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

export const ContactForm = component$(() => {
  const status = useSignal<"idle" | "sending" | "ok" | "error">("idle");

  const onSubmit = $(async (_event: SubmitEvent, form: HTMLFormElement) => {
    status.value = "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) throw new Error(data.error ?? "HTTP " + res.status);
      form.reset();
      status.value = "ok";
    } catch {
      status.value = "error";
    }
  });

  return status.value === "ok" ? (
    <p>Thanks, we will reply by email.</p>
  ) : (
    <form action={ENDPOINT} method="post" preventdefault:submit 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.value === "sending"}>
        {status.value === "sending" ? "Sending..." : "Send"}
      </button>
      {status.value === "error" && <p role="alert">Could not send. Please try again.</p>}
    </form>
  );
});

Success and error handling

  • Event handlers in Qwik run lazily, so they cannot call event.preventDefault() in time. The preventdefault:submit attribute tells Qwik's loader to stop the browser's own submit before the handler is fetched.
  • The handler's second argument is the form element itself, which is what FormData and form.reset() need.
  • The form keeps its action and method, so a visitor whose JavaScript fails still gets a normal POST and Formward's 302 redirect. Add a hidden _redirect field with a path such as /contact/thanks to send them back to your site.
  • On Qwik 2 the imports move to @qwik.dev/core, and Qwik City becomes @qwik.dev/router. The component code is the same.

Forwarding from a Qwik City routeAction$

Use this when your server must check or add to the data before it leaves your app. The action receives the submitted fields, forwards them to Formward with Accept: application/json, and returns a result the page can read. Qwik City's Form component works without JavaScript and is enhanced when it loads.

Forward the honeypot and, if the form uses Turnstile, the cf-turnstile-response token unchanged, or Formward cannot run those checks. Two side effects come with posting from a server: the request carries no Origin header, so a form with an allowed-origins list refuses it with a 403, and every visitor arrives from your server's IP address, so they share one per-IP rate-limit bucket (50 submissions a minute by default, adjustable per form).

An action needs a running server, so this version does not work on a fully static build. The component above does.

tsx

// src/routes/contact/index.tsx
import { component$ } from "@builder.io/qwik";
import { routeAction$, Form } from "@builder.io/qwik-city";

const ENDPOINT = "https://forms.formward.eu/f/<FORM_ID>";
const FIELDS = ["name", "email", "message", "_gotcha", "cf-turnstile-response"];

export const useContact = routeAction$(async (input, { fail }) => {
  const email = String(input.email ?? "");
  if (!email.includes("@")) return fail(400, { message: "Enter a valid email address." });

  const body = new FormData();
  for (const key of FIELDS) {
    const value = input[key];
    if (typeof value === "string") body.append(key, value);
  }

  const res = await fetch(ENDPOINT, {
    method: "POST",
    headers: { Accept: "application/json" },
    body,
  });
  const data = await res.json().catch(() => null);
  if (!res.ok || !data?.ok) return fail(502, { message: "Could not send. Please try again." });
  return { success: true };
});

export default component$(() => {
  const action = useContact();
  if (action.value?.success) return <p>Thanks, we will reply by email.</p>;

  return (
    <Form action={action}>
      <input name="name" placeholder="Your name" required />
      <input type="email" name="email" placeholder="you@example.com" required />
      <textarea name="message" placeholder="Your message" required />
      <input type="text" name="_gotcha" tabIndex={-1} autoComplete="off"
        style={{ position: "absolute", left: "-5000px" }} aria-hidden="true" />
      <button type="submit" disabled={action.isRunning}>
        {action.isRunning ? "Sending..." : "Send"}
      </button>
      {action.value?.failed && <p role="alert">{action.value.message}</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 Qwik submission

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

Qwik contact form without a backend | Formward