Back to blog

August 27, 2026 · NBForms Team

Astro Form Backend: A Contact Form That Still Works With JavaScript Off

Astro ships zero JavaScript by default. Here's a contact form that respects that — working as plain HTML first, with AJAX feedback layered on top, not required.

Astro's whole pitch is that a page shouldn't ship JavaScript it doesn't need, and a contact form is a good test of whether a tool actually believes that. The honest version of an Astro contact form is one that works before any script runs, and gets a little nicer once one does — not one that quietly requires JavaScript because that was the easier way to write the tutorial.

Green code scrolling on a dark terminal screen Photo by Jake Walker on Unsplash

The baseline: zero JavaScript, and it already works

<form id="nbform" action="https://api.nbforms.com" method="POST">
  <input type="hidden" name="_token" value="YOUR_TOKEN" />
  <label>Name</label>
  <input type="text" name="name" required />
  <label>Email</label>
  <input type="email" name="email" required />
  <label>Message</label>
  <textarea name="message" required></textarea>
  <button type="submit">Send</button>
</form>

Delete every line below this point in your project and the form still works — a plain HTML POST, handled entirely by the browser, no Astro-specific behavior involved. That's true regardless of whether the page it's on was built with output: 'static', rendered per-request with output: 'server', or marked prerender = false in a hybrid setup, because none of those settings change what a <form> tag does. The request leaves the browser and goes to api.nbforms.com directly; Astro's own rendering mode governs how the page got built, not where the form's data goes afterward.

That also means the host doesn't matter. Deploy the same project to Netlify, Vercel, Cloudflare Pages, or a plain static bucket and the form behaves identically everywhere, because nothing about it depends on which platform is serving the built files — there's no platform-specific function, edge config, or environment variable involved anywhere in either version below.

Layering on the enhancement

<script>
  const form = document.getElementById('nbform')
  form.addEventListener('submit', async (e) => {
    e.preventDefault()
    const res = await fetch(form.action, { method: 'POST', body: new FormData(form) })
    const data = await res.json()
    if (res.ok) {
      form.hidden = true
      const p = document.getElementById('nbsuccess')
      p.textContent = data.message ?? 'Thanks!'
      p.hidden = false
    }
  })
</script>

Add this and a hidden <p id="nbsuccess"> next to the form, and a visitor with JavaScript enabled gets an in-place success message instead of a full navigation — the same request, submitted the same way, with a nicer result. Nothing about the baseline form changes to support this; the script finds it by id and attaches to it, which is what makes the enhancement optional rather than load-bearing.

No client directive, because there's no island here

Reaching for client:load or client:visible on a component is the normal way to add interactivity in Astro, which makes it easy to assume a form needs one too. It doesn't, because nothing here is a framework component being hydrated — it's a <script> tag sitting in an .astro file, which Astro passes through to the built page as-is and the browser runs like any inline script on any HTML page. There's no hydration boundary, no island boundary, and no framework runtime loaded for it. If a form like this lived inside a React or Vue island instead, the whole calculus would change: a client:load island ships that framework's runtime to the browser just to manage a few inputs and a submit handler, adding real, measurable bytes to the page for functionality a dozen lines of vanilla JavaScript already cover. A vanilla <script> needs none of that machinery to begin with, which is less a limitation than the reason Astro shipped one in the first place.

The redirect setting's split personality

A redirect URL configured in the form's settings serves the no-JavaScript fallback well: a native POST followed by a 302 is exactly what a plain form submission expects, landing the visitor on a real page instead of the raw JSON text a native submission would otherwise render without one. The same setting works against the enhanced path above, because fetch follows redirects transparently — res.json() would receive whatever HTML lives at the redirect target instead of the JSON response the script expects, and throw. If no-JavaScript visitors are rare enough not to design around, leaving the redirect unset keeps the enhanced path simple and accepts a plain-text fallback for the few who need it. If they're common enough to matter, set the redirect and wrap the script's res.json() call in a try/catch that treats a parse failure as success rather than an error — a redirect from NBForms only ever fires after a submission was already accepted, so a failed parse there is a false negative, not a real one.

One component, dropped onto any page

Both blocks above belong together in a single file — src/components/ContactForm.astro — with the form markup and its <script> in the same place a React or Vue developer would keep them in one component file. Nothing about Astro components requires splitting markup from behavior into separate files the way some frameworks encourage; a <ContactForm /> import at the top of any .astro page, and <ContactForm /> wherever it belongs in the markup, is the entire integration. Because there's no props needed beyond what's hardcoded in the component (the token, the field list), the same file works unmodified across a contact page, a footer, and a dedicated landing page without threading configuration through each usage.

View Transitions changes when the script needs to run

Astro's View Transitions swap page content on navigation instead of doing a full browser reload, which is exactly the kind of thing that breaks a script written to run once on load. A plain top-level <script>, or one attached to DOMContentLoaded, only fires on the very first page load — navigate to the contact page client-side afterward, via a transition, and the script never runs again, so document.getElementById('nbform') in a fresh copy of the page finds nothing listening to it. Astro's own fix for this is the astro:page-load event, which fires on every navigation, transitions included:

document.addEventListener('astro:page-load', () => {
  const form = document.getElementById('nbform')
  form?.addEventListener('submit', async (e) => {
    // same handler as above
  })
})

Sites without View Transitions enabled don't need this — the original version at the top of this post is complete as written. It only matters once <ViewTransitions /> (or the newer transition:animate conventions) is in play somewhere in the layout, at which point every script tied to a specific page, this one included, needs to be aware that "page load" can now happen more than once per actual browser navigation.

What it doesn't do

Same scope as any lightweight form backend, worth stating plainly rather than implying more: this receives a submission, filters it for spam, stores it, and emails an alert. No payment processing, no multi-step form logic, and no automatic reply to whoever submitted beyond the success message shown here. A contact form asking to be read doesn't need more than that; a form that needs to run its own logic on submission still needs a real endpoint somewhere, Astro output: 'server' route or otherwise — and reaching for one there is a legitimate, different decision, not a sign this pattern fell short of what it was ever meant to do.

The Astro snippet reference has this code with no prose around it. For the file-upload and spam-filtering mechanics that apply identically here, see adding a contact form without a backend and how server-side spam filtering works.

Frequently asked questions

Does this need a client:load or client:visible directive?

No — those directives hydrate framework components (a React or Vue island), and this isn't one. It's a plain inline <script> tag in an .astro file, which runs in the browser exactly like a script tag on any HTML page, no hydration step involved.

Does this work in Astro's static (SSG), server (SSR), and hybrid output modes?

Yes, identically in all three. The form and its script are static markup either way — nothing about them depends on how Astro rendered the page they're on, since the submission goes to an external endpoint, not back to an Astro route.

Should I set a redirect URL in the form's settings?

It depends which path matters more to you. A redirect helps the no-JavaScript fallback (a real page navigation after a native POST) but will break the enhanced script's res.json() call, since fetch follows redirects transparently. Pick one, or branch on it in the script — covered below.

What happens if a visitor has JavaScript disabled?

The form still works. The script never attaches its listener, so nothing calls preventDefault, and the browser falls back to a normal POST-and-navigate submission — the same request, just without the inline success message.

Do I need a bundler, npm package, or build step for the script?

No. It's plain browser JavaScript inside the .astro file, processed by Astro's own build like any other inline script — no separate dependency to install or configure.