Svelte

A Svelte contact form component that emails you every submission, with loading and error states.

A Svelte app built with Vite compiles to static JavaScript and HTML, so the contact form has nothing on the server to talk to. Point it at SilentForms instead: the component posts the fields and every submission arrives in your inbox. This guide uses Svelte 5 runes. If you use SvelteKit, read the SvelteKit guide, which covers prerendering and $env.

What you'll build#

A ContactForm.svelte component that:

  • keeps its status in $state,
  • reads the fields with FormData and posts them with fetch,
  • shows sending, success and error states and announces them to screen readers,
  • keeps the hidden honeypot field.

1. Get an access key and expose it to Vite#

Get a key on the access key page and add it to .env.local:

VITE_SILENTFORMS_KEY=YOUR_ACCESS_KEY_HERE

Vite passes only VITE_-prefixed variables to the browser, as import.meta.env.VITE_SILENTFORMS_KEY, and inlines them at build time. The key is not a secret: it only tells SilentForms where to deliver. Restart the dev server after editing .env.local and add the variable to your host's build settings.

2. The component#

Create src/lib/ContactForm.svelte:

<script>
  const ACCESS_KEY = import.meta.env.VITE_SILENTFORMS_KEY;

  let status = $state('idle'); // idle | sending | sent | error
  let feedback = $state('');

  async function handleSubmit(event) {
    event.preventDefault();
    const form = event.currentTarget;
    const data = new FormData(form);
    data.append('accessKey', ACCESS_KEY);

    status = 'sending';
    feedback = '';
    try {
      const response = await fetch('https://silentforms.com/api/submit', {
        method: 'POST',
        headers: { Accept: 'application/json' },
        body: data,
      });
      const result = await response.json();
      if (!response.ok) throw new Error(result.message);
      form.reset();
      status = 'sent';
      feedback = 'Thanks! Your message has been sent.';
    } catch (error) {
      status = 'error';
      feedback = error.message || 'Something went wrong. Please try again.';
    }
  }
</script>

<form onsubmit={handleSubmit}>
  <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'}>
    {status === 'sending' ? 'Sending…' : 'Send message'}
  </button>

  <p role={status === 'error' ? 'alert' : 'status'}>{feedback}</p>
</form>

<style>
  form {
    display: grid;
    gap: 0.5rem;
    max-width: 32rem;
  }
  [role='alert'] {
    color: #b91c1c;
  }
</style>

Then render it, for example from src/App.svelte:

<script>
  import ContactForm from './lib/ContactForm.svelte';
</script>

<main>
  <h1>Contact</h1>
  <ContactForm />
</main>

Svelte 5 details#

  • Event attributes, not directives. Svelte 5 uses onsubmit={handleSubmit} instead of on:submit, and the |preventDefault modifier is gone. Call event.preventDefault() yourself, as above. The old on:submit|preventDefault syntax is deprecated, and a component cannot mix it with event attributes.
  • $state for anything the markup reads. Plain let variables are no longer reactive in runes mode, so status and feedback must be declared with $state, or the button and message would never update.
  • No bind:value needed. The fields are read once, on submit, so FormData is simpler than binding each one. If you want live validation, declare let email = $state(''), use bind:value={email} and build the request body from those variables.
  • Scoped styles. The <style> block only applies to this component, so the [role='alert'] rule will not leak into the rest of your app.

The browser runs its required and type="email" checks before onsubmit fires, so an empty or malformed field never reaches fetch. SilentForms still validates the email field; if it rejects it, the message "Email address is invalid." appears under the button.

Testing#

  1. Run npm run dev and send yourself a message. The button reads "Sending…", the form clears and the email arrives.
  2. Open the Network tab: the request should be multipart/form-data with accessKey, name, email and message, and no botcheck (unchecked boxes are not sent).
  3. Build with npm run build and check npm run preview. If the form says "Access key is required.", the environment variable was missing at build time.

Next steps#