Alpine.js
A lightweight Alpine.js contact form that submits with fetch and shows the result inline. Drop it into any HTML page.
Alpine.js fits the pages where a contact form usually lives: a mostly static HTML site that needs a sprinkle of behaviour, with no build step. One x-data component submits the form with fetch, shows the result in place, and falls back to a normal HTML post if the script never loads.
What you'll build#
A single HTML snippet that:
- loads Alpine from a CDN,
- posts the form to SilentForms with
fetchand stays on the page, - shows "Sending…", a success message or the endpoint's error message inline,
- still works without JavaScript, because the form keeps its
action.
Get an access key on the access key page first. Alpine has no environment variables, so the key goes into the HTML as a hidden field. That is safe: it only routes submissions to your inbox.
1. Load Alpine#
Add the script tag from the Alpine installation docs to the <head>:
<script defer src="https://cdn.jsdelivr.net/npm/[email protected]/dist/cdn.min.js"></script>defer matters: Alpine has to start after the page's HTML has been parsed. For production, replace 3.x.x with the exact version you tested (see the Alpine installation guide) so a new release cannot change your page unexpectedly.
2. The form component#
<form
action="https://silentforms.com/api/submit"
method="POST"
x-data="contactForm"
@submit.prevent="submit"
>
<input type="hidden" name="accessKey" value="YOUR_ACCESS_KEY_HERE">
<label for="cf-name">Name</label>
<input id="cf-name" name="name" type="text" autocomplete="name" required>
<label for="cf-email">Email</label>
<input id="cf-email" name="email" type="email" autocomplete="email" required>
<label for="cf-message">Message</label>
<textarea id="cf-message" name="message" rows="5" required></textarea>
<input type="checkbox" name="botcheck" style="display: none;" tabindex="-1" autocomplete="off">
<button type="submit" :disabled="status === 'sending'" x-text="status === 'sending' ? 'Sending…' : 'Send message'">
Send message
</button>
<p :role="status === 'error' ? 'alert' : 'status'" x-text="feedback"></p>
</form>
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('contactForm', () => ({
status: 'idle', // idle | sending | sent | error
feedback: '',
async submit() {
const form = this.$root;
this.status = 'sending';
this.feedback = '';
try {
const response = await fetch(form.action, {
method: 'POST',
headers: { Accept: 'application/json' },
body: new FormData(form),
});
const result = await response.json();
if (!response.ok) throw new Error(result.message);
form.reset();
this.status = 'sent';
this.feedback = 'Thanks! Your message has been sent.';
} catch (error) {
this.status = 'error';
this.feedback = error.message || 'Something went wrong. Please try again.';
}
},
}));
});
</script>Keep the <script> block a plain inline script, anywhere in the page. Inline scripts run while the HTML is parsed and the deferred CDN script runs afterwards, so the alpine:init listener is always registered before Alpine starts.
How it works#
Alpine.datakeeps logic out of the attribute. An inlinex-data="{ ... }"object works too, but an async method withtry/catchis hard to read inside an HTML attribute. Registering the component inalpine:initgives you a normal JavaScript function and a reusable name, so several forms on a page can share it.this.$rootis the element carryingx-data, here the<form>itself.new FormData(form)picks up every named field, including the hiddenaccessKey, so the key is written only once.@submit.preventruns after the browser's own validation. Fields markedrequiredortype="email"stop the submit beforefetchis called.Accept: application/jsonmakes SilentForms answer with JSON instead of redirecting. Errors come back with a readablemessage, such as "Email address is invalid.", whichx-textputs straight into the page.x-textsets text, never HTML, so a message can't inject markup.- The button text lives in the HTML too ("Send message"), so it is visible before Alpine initialises.
Without JavaScript#
If Alpine fails to load, @submit.prevent never runs and the browser posts the form to the action URL as a regular form. SilentForms then redirects to its default thank-you page. To send those visitors to your own page instead, add a hidden field:
<input type="hidden" name="redirect" value="https://example.com/thanks/">JSON submissions ignore redirect, so the Alpine path is unaffected. More in Redirect after submit.
Gotchas#
- Content Security Policy. The standard Alpine build evaluates expressions with
new Function, which a strict CSP (nounsafe-eval) blocks. Alpine publishes a separate CSP build for that case; it supports most simple expressions but not arrow functions, assignments to properties or globals such asdocument, which is another reason to keep the logic inAlpine.data. Move the inline<script>into a file as well, since a strict CSP blocks inline scripts too. See the Alpine CSP docs. - Flash of unstyled state. Elements that should start hidden, such as a success panel with
x-show, needx-cloakplus a[x-cloak] { display: none; }rule, or they flash before Alpine runs. The status paragraph above is empty at first, so it does not need it.
Testing#
- Open the page, submit the form and check that the message appears under the button and the email arrives.
- Block the Alpine script in dev tools (Network tab, "Block request URL") and submit again: the browser should leave the page for the thank-you page.
- If the console warns that
contactFormis not defined, thealpine:initlistener was registered after Alpine had already started, for example from atype="module"script. Keep it a plain inline<script>.
Next steps#
- The same pattern in plain JavaScript: AJAX form with JavaScript.
- Add Cloudflare Turnstile for a CAPTCHA.
- Put your site's name on the emails: Sender name.