Svelte 5 contact form after the event modifiers
If you copied a Svelte contact form from a tutorial written before Svelte 5, it quietly stopped working. Event modifiers were removed in Svelte 5, so on:submit|preventDefault is no longer a thing and the page navigates away mid-submit. The handler calls preventDefault itself now:
<script>
const ENDPOINT = "https://api.formroute.dev/f/site_xyz";
let status = $state("idle");
async function send(event) {
event.preventDefault();
status = "submitting";
try {
const response = await fetch(ENDPOINT, {
method: "POST",
body: new FormData(event.currentTarget),
});
status = response.ok ? "success" : "error";
} catch {
status = "error";
}
}
</script>
{#if status === "success"}
<p>Thanks, we got it.</p>
{:else}
<form onsubmit={send}>
<input name="email" type="email" required />
<textarea name="message" required></textarea>
<button disabled={status === "submitting"}>
{status === "submitting" ? "Sending…" : "Send"}
</button>
{#if status === "error"}
<p role="alert">Something went wrong. Try again.</p>
{/if}
</form>
{/if}
The three things Svelte 5 changed here
Each of these fails differently, and two of them fail silently:
| Svelte 4 | Svelte 5 | What happens if you leave it |
|---|---|---|
on:submit={fn} |
onsubmit={fn} |
The handler never fires |
on:submit|preventDefault |
event.preventDefault() in the handler |
The page navigates away mid-request |
let status = "idle" |
let status = $state("idle") |
Renders once, then never updates |
The third is the cruel one. A bare let compiles without a warning in a runes component, the form renders correctly, and the button simply never changes to “Sending…”.
Vue kept its modifiers, which is why the Vue contact form still uses @submit.prevent for the same job.
bind:value is optional
new FormData(form) reads every named field out of the DOM at submit time. That is why nothing above is bound: the fields already hold their values, and copying them into runes on every keystroke buys nothing unless something reads them while typing.
Bind a field when there is live feedback attached to it:
<script>
let message = $state("");
let tooShort = $derived(message.length > 0 && message.length < 10);
</script>
<textarea name="message" bind:value={message} required></textarea>
{#if tooShort}<p>A few more words would help.</p>{/if}
$derived is the right tool for that, not a $effect writing into another piece of state. Keep the name attribute either way, or FormData skips the field entirely.
SvelteKit form actions need a server
SvelteKit has form actions and use:enhance, and they solve a real problem: the form works without JavaScript and gets progressively enhanced once it loads. They also need a server to run the action, which means an adapter that provides one.
| Setup | Form actions | What the form does |
|---|---|---|
| SvelteKit + node, vercel or cloudflare adapter | Available | The action can forward the submission server-side |
SvelteKit + adapter-static |
Not available | Posts straight to the endpoint |
| Svelte without Kit | Not applicable | Posts straight to the endpoint |
With adapter-static there is no server in the output, so form actions are off the table and the component above is what you ship. If you are on an adapter that does give you a server, it is still worth asking what the action adds: hiding the endpoint is not a reason, because a form endpoint is public by design.
Keep the form mounted when it fails
The {#if} above swaps the form out on success and leaves it in place on error. That ordering is deliberate: if a failed request clears the fields, the visitor retypes everything and usually leaves. Unbound inputs give you this for free, since the DOM still holds what they wrote.
required and any $derived check are courtesies to the honest visitor, and both vanish for anything that is not your component. The rules have to hold at the endpoint instead, which is what a form backend does with them.
After the fetch resolves
Everything above ends the moment response.ok comes back true. The contact form is not finished there: the message still has to survive spam filtering, reach someone who will read it, and stay findable next month. That is the half no framework version solves.
Runes are not the hard part. The endpoint behind the form is, and FormRoute is in private access while the first sites come on. Request an invite.