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 fetch and 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.data keeps logic out of the attribute. An inline x-data="{ ... }" object works too, but an async method with try/catch is hard to read inside an HTML attribute. Registering the component in alpine:init gives you a normal JavaScript function and a reusable name, so several forms on a page can share it.
  • this.$root is the element carrying x-data, here the <form> itself. new FormData(form) picks up every named field, including the hidden accessKey, so the key is written only once.
  • @submit.prevent runs after the browser's own validation. Fields marked required or type="email" stop the submit before fetch is called.
  • Accept: application/json makes SilentForms answer with JSON instead of redirecting. Errors come back with a readable message, such as "Email address is invalid.", which x-text puts straight into the page. x-text sets 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 (no unsafe-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 as document, which is another reason to keep the logic in Alpine.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, need x-cloak plus 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#

  1. Open the page, submit the form and check that the message appears under the button and the email arrives.
  2. 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.
  3. If the console warns that contactForm is not defined, the alpine:init listener was registered after Alpine had already started, for example from a type="module" script. Keep it a plain inline <script>.

Next steps#