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
FormDataand posts them withfetch, - 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_HEREVite 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 ofon:submit, and the|preventDefaultmodifier is gone. Callevent.preventDefault()yourself, as above. The oldon:submit|preventDefaultsyntax is deprecated, and a component cannot mix it with event attributes. $statefor anything the markup reads. Plainletvariables are no longer reactive in runes mode, sostatusandfeedbackmust be declared with$state, or the button and message would never update.- No
bind:valueneeded. The fields are read once, on submit, soFormDatais simpler than binding each one. If you want live validation, declarelet email = $state(''), usebind: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#
- Run
npm run devand send yourself a message. The button reads "Sending…", the form clears and the email arrives. - Open the Network tab: the request should be
multipart/form-datawithaccessKey,name,emailandmessage, and nobotcheck(unchecked boxes are not sent). - Build with
npm run buildand checknpm run preview. If the form says "Access key is required.", the environment variable was missing at build time.
Next steps#
- Using SvelteKit with
adapter-static? See SvelteKit. - Add Cloudflare Turnstile for extra spam protection.
- Choose the subject line of the email: Email subject.