React framework
Remix form handling without a backend
Remix and its successor, React Router v7 in framework mode, push every form toward a route action on your own server. For a contact form that action usually ends up sending an email and writing a row somewhere. With Formward you can skip it: the form posts to your Formward endpoint, which stores the submission in Sweden, filters spam and emails you.
There are two ways to wire it, and the choice depends on whether your server needs to see the data. The first example is a plain HTML form that posts straight to Formward. The second keeps Remix's Form and action, and the action forwards the fields. The code uses React Router v7 imports; the note under each example covers Remix v2.
EU-hosted · Your Remix app can run on any host or edge runtime while the contact data goes to Formward and is stored in Sweden.
The Remix example
Copy this into your project and replace <FORM_ID> with the id of a form you create in the Formward dashboard.
tsx
// app/routes/contact.tsx
// A lowercase <form>: the browser posts straight to Formward.
// Remix's <Form> would send it to a route action in your app instead.
const ENDPOINT = "https://forms.formward.eu/f/<FORM_ID>";
export default function Contact() {
return (
<form action={ENDPOINT} method="post">
<input name="name" placeholder="Your name" required />
<input type="email" name="email" placeholder="you@example.com" required />
<textarea name="message" placeholder="Your message" required />
{/* Formward sends the visitor back to this path on your site. */}
<input type="hidden" name="_redirect" value="/contact/thanks" />
<input type="text" name="_gotcha" tabIndex={-1} autoComplete="off"
style={{ position: "absolute", left: "-5000px" }} aria-hidden />
<button type="submit">Send</button>
</form>
);
}Success and error handling
- This version has no action and no loader, so it is identical in Remix v2 and React Router v7. The page needs no JavaScript to submit.
- After the POST, Formward answers with a 302. A path in _redirect is resolved against your site; a full URL is only followed when its origin is on the form's allowed-origins list. A redirect URL set in the form's settings works too.
- Use this version unless your server has to see or change the data first. It keeps visitors' real IP addresses in front of Formward's rate limit, and the form's origin allowlist keeps working.
Forwarding from a route action
Keep Remix's Form when you want server-side validation, pending UI from useNavigation, or the result in useActionData. The action reads the fields, checks them and forwards a fresh FormData to Formward with Accept: application/json, so it gets JSON back instead of a redirect.
Forward the honeypot and, if the form uses Turnstile, the cf-turnstile-response token, or Formward cannot run those checks. Two side effects come with posting from a server: the request has no Origin header, so a form with an allowed-origins list refuses it with a 403, and all visitors share your server's IP address and with it one per-IP rate-limit bucket (50 submissions a minute by default, adjustable per form).
On Remix v2, import Form, useActionData and useNavigation from @remix-run/react and ActionFunctionArgs from @remix-run/node, and wrap the action's return values in json() unless you have turned on Single Fetch.
tsx
// app/routes/contact.tsx
import { Form, useActionData, useNavigation } from "react-router";
import type { ActionFunctionArgs } from "react-router";
const ENDPOINT = "https://forms.formward.eu/f/<FORM_ID>";
const FIELDS = ["name", "email", "message", "_gotcha", "cf-turnstile-response"];
export async function action({ request }: ActionFunctionArgs): Promise<{ ok: boolean; error: string | null }> {
const input = await request.formData();
const email = String(input.get("email") ?? "");
if (!email.includes("@")) return { ok: false, error: "Enter a valid email address." };
const body = new FormData();
for (const key of FIELDS) {
const value = input.get(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 { ok: false, error: "Could not send. Please try again." };
return { ok: true, error: null };
}
export default function Contact() {
const result = useActionData<typeof action>();
const sending = useNavigation().state === "submitting";
if (result?.ok) return <p>Thanks, we will reply by email.</p>;
return (
<Form method="post">
<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 />
<button type="submit" disabled={sending}>{sending ? "Sending..." : "Send"}</button>
{result?.error && <p role="alert">{result.error}</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 Remix submission
Create a form, paste the snippet, and keep every submission in the EU. GDPR-clean from the first POST.