Content framework
Astro form handling without a backend
To handle a form in Astro without a backend or an SSR adapter, use a plain HTML form whose action posts to your Formward endpoint, and optionally add a fetch() call for in-page feedback. The site stays fully static, with no server endpoint and no database.
The simplest version is a plain HTML <form> with an action; it works with zero JavaScript and Formward responds with a redirect or thank-you page. The example also adds a small inline <script> so you can submit via fetch() and show success or error in place when JavaScript is available.
EU-hosted · A static Astro site with an EU form endpoint is the cleanest GDPR story there is: nothing dynamic to host, and no form data leaving the EU.
The Astro example
Copy this into your project and replace <FORM_ID> with the id of a form you create in the Formward dashboard.
astro
---
// src/components/ContactForm.astro - no server code, fully static.
---
<form id="contact" action="https://forms.formward.eu/f/<FORM_ID>" method="POST">
<input type="email" name="email" required />
<textarea name="message" required></textarea>
<!-- Honeypot: leave empty. Bots that fill it are dropped. -->
<input type="text" name="_gotcha" tabindex="-1" autocomplete="off"
style="display:none" aria-hidden="true" />
<button type="submit">Send</button>
<p id="contact-status" role="status"></p>
</form>
<script>
const form = document.querySelector<HTMLFormElement>("#contact");
const out = document.querySelector<HTMLParagraphElement>("#contact-status");
if (form && out) form.addEventListener("submit", async (e) => {
e.preventDefault();
out.textContent = "Sending…";
try {
const res = await fetch(form.action, {
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();
out.textContent = "Thanks - we received your message.";
} catch {
out.textContent = "Could not send. Please try again.";
}
});
</script>Success and error handling
- Without the <script>, the form still works: a no-JS POST gets a 302 redirect (or your configured thank-you page), so the form degrades gracefully.
- With the <script>, e.preventDefault() plus the Accept: application/json header keeps the visitor on the page and gives you the { ok, id, files } JSON.
- Astro strips the inline script into its own module at build time; the form action still points at Formward either way.
Use it on a page
Import the component into any page. With the default static output, astro build writes plain HTML and the form keeps working on any static host, because the POST goes to Formward and never to your site.
To send visitors back to your own thank-you page after a no-JS submit, add a hidden _redirect field with a path such as /thanks, or set the redirect once in the form's settings.
astro
---
// src/pages/contact.astro
import Layout from "../layouts/Layout.astro";
import ContactForm from "../components/ContactForm.astro";
---
<Layout title="Contact">
<main>
<h1>Get in touch</h1>
<ContactForm />
</main>
</Layout>Static, SSR or islands: none of them needs your own backend
Static output (the default): astro build writes HTML, the form posts straight to Formward, and the site can live on GitHub Pages, Cloudflare Pages, Netlify, Vercel or any file host. This is the common case and the example above.
Server output with an adapter: the page is rendered per request, but the form still posts to Formward. There is no reason to write an API route to receive the submission, forward it to email and store it; that is what the endpoint already does, with spam filtering and retries you would otherwise build yourself.
Islands (React, Svelte, Vue or Solid components with client:load): keep the native form element and its action as the fallback, and send a fetch() with Accept: application/json from the component for inline success and error states. The JSON contract is the same as for the vanilla script in the HTML example.
Unlike Netlify Forms, nothing here depends on where the site is deployed, and submissions are stored in Sweden rather than with the host.
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 Astro submission
Create a form, paste the snippet, and keep every submission in the EU. GDPR-clean from the first POST.