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.
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.