React framework
Next.js form handling without a backend
A contact form in Next.js tends to grow a route handler, an email provider and a table to store the rows. Formward replaces all three with one URL. The browser (or your server action) posts the form to your Formward endpoint, and the submission is spam-filtered, stored in Sweden and emailed to you.
This page has three working versions for the App Router: a client component that posts with fetch, which is the one most sites want; a plain form that works with JavaScript turned off; and a server action for when you need to run your own code before the data leaves your app. They share the endpoint and the field names, so you can switch later without touching the dashboard.
EU-hosted · Your Next.js app can run on any host. The names, emails and messages it collects are received and stored by Formward in Sweden.
The Next.js example
Copy this into your project and replace <FORM_ID> with the id of a form you create in the Formward dashboard.
tsx
// app/contact/contact-form.tsx
"use client";
import { useState, type FormEvent } from "react";
const ENDPOINT = "https://forms.formward.eu/f/<FORM_ID>";
export function ContactForm() {
const [status, setStatus] = useState<"idle" | "sending" | "ok" | "error">("idle");
async function onSubmit(e: FormEvent<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),
});
// { ok: true, id, files } on success, { ok: false, error } otherwise
const data = await res.json();
if (!res.ok || !data.ok) throw new Error(data.error ?? `HTTP ${res.status}`);
form.reset();
setStatus("ok");
} catch {
setStatus("error");
}
}
if (status === "ok") return <p>Thanks, we will reply by email.</p>;
return (
<form 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 />
<button type="submit" disabled={status === "sending"}>
{status === "sending" ? "Sending..." : "Send"}
</button>
{status === "error" && <p role="alert">Could not send. Please try again.</p>}
</form>
);
}Success and error handling
- The Accept: application/json header switches Formward from its 302 redirect to a JSON reply, so the visitor stays on the page and your component decides what to show.
- Error statuses are specific: 422 for a failed field rule (the body names the field), 429 when one address sends too fast (with a Retry-After header), 402 when the account is over its monthly limit, 403 for an origin outside the form's allowlist or a failed Turnstile check.
- If you set a redirect URL in the form's settings, the JSON reply also carries redirect, so the component can send the visitor to your thank-you page itself.
- The same component works unchanged in the Pages Router, where the "use client" line has no effect.
Which version to use
Client fetch (above): the default. No server code, works on a static export, and the visitor gets inline feedback. The endpoint URL is visible in your bundle, which is fine: it is a public form URL, and the rate limits, honeypot and origin allowlist are enforced by Formward, not by keeping the URL secret.
Plain form POST: for pages that must work without JavaScript, or when you would rather not ship a client component at all. The browser follows a redirect after submitting.
Server action: only when you need to run code on your server first, such as checking the input against your own database or adding a value the browser should not supply. It costs a round trip through your server and has two side effects, explained below. A route handler that forwards the request behaves the same way, so there is no reason to write one just for Formward.
The architecture question (why not your own route handler at all) has its own write-up in the blog post linked at the end of this page.
Render it from a server page
Keep the page itself a server component and import the client form. Next.js ships only the form's JavaScript to the browser; the rest of the page stays server-rendered.
tsx
// app/contact/page.tsx
import type { Metadata } from "next";
import { ContactForm } from "./contact-form";
export const metadata: Metadata = { title: "Contact" };
export default function ContactPage() {
return (
<main>
<h1>Get in touch</h1>
<ContactForm />
</main>
);
}A plain form that works without JavaScript
A server component can render a normal HTML form whose action is the Formward endpoint. Nothing runs in the browser. After the POST, Formward answers with a 302 to your thank-you page.
The hidden _redirect field takes a path on your own site, which Formward resolves against the page the form was posted from. A full URL in _redirect is only followed when its origin is on the form's allowed-origins list. You can instead set a redirect URL once in the form's settings and leave the field out.
tsx
// app/contact/page.tsx (no client component needed)
const ENDPOINT = "https://forms.formward.eu/f/<FORM_ID>";
export default function ContactPage() {
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 />
<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>
);
}Posting from a server action
The action receives the form's FormData on your server, can validate it, and forwards a fresh FormData to Formward. Forward the honeypot and, if the form uses Turnstile, the cf-turnstile-response token unchanged, or Formward cannot run those checks.
Two side effects to know before choosing this. A request from your server carries no Origin header, so if the form has an allowed-origins list, Formward refuses it with a 403; leave the list empty for a form you post to from a server. And every visitor now 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).
tsx
// app/contact/actions.ts
"use server";
const ENDPOINT = "https://forms.formward.eu/f/<FORM_ID>";
const FIELDS = ["name", "email", "message", "_gotcha", "cf-turnstile-response"];
export type ContactState = { ok: boolean; error: string | null } | null;
export async function sendContact(_prev: ContactState, input: FormData): Promise<ContactState> {
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 };
}
// app/contact/contact-form.tsx
"use client";
import { useActionState } from "react";
import { sendContact, type ContactState } from "./actions";
export function ContactForm() {
const [state, formAction, pending] = useActionState<ContactState, FormData>(sendContact, null);
if (state?.ok) return <p>Thanks, we will reply by email.</p>;
return (
<form action={formAction}>
<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={pending}>{pending ? "Sending..." : "Send"}</button>
{state?.error && <p role="alert">{state.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 Next.js submission
Create a form, paste the snippet, and keep every submission in the EU. GDPR-clean from the first POST.