splitforms.com
SVELTE · CONTACT FORM

Contact form for Svelte websites

SvelteKit, classic Svelte, Vite, Astro islands — pick your flavor. One reactive component, full form handling, no backend route. Works with Svelte 4 stores and Svelte 5 runes.

1,000 free submissions every month.·No credit card.
contact.sveltesvelte29 lines
01<script>
02 let status = "idle";
03
04 async function onSubmit(e) {
05 status = "loading";
06 const formData = new FormData(e.target);
07 formData.append("access_key", "YOUR_ACCESS_KEY");
08
09 const res = await fetch("https://splitforms.com/api/submit", {
10 method: "POST",
11 body: formData,
12 });
13 const data = await res.json();
14 status = data.success ? "ok" : "err";
15 if (data.success) e.target.reset();
16 }
17</script>
18
19<form on:submit|preventDefault={onSubmit}>
20 <input name="name" required />
21 <input name="email" type="email" required />
22 <textarea name="message" required />
23 <input type="checkbox" name="botcheck" style="display:none" tabindex="-1" />
24 <button disabled={status === "loading"}>
25 {status === "loading" ? "Sending…" : "Send"}
26 </button>
27 {#if status === "ok"}<p>Thanks!</p>{/if}
28 {#if status === "err"}<p>Error.</p>{/if}
29</form>
1,000
submissions / mo, free
14ms
median latency, edge
0
lines of backend code
17+
frameworks supported
✶ Live preview

What your Svelte contact form actually looks like.

Drop-in form backend with spam filtering, signed webhooks, and a real submissions dashboard. The same code in this preview is what you copy into your Svelte project — no SDK, no plugin, no PHP.

  • 1,000 submissions per month, free forever
  • Honeypot + AI spam classifier on every plan
  • Signed webhooks to Slack, Discord, your server
Svelte contact form on Splitforms — drop-in form backend with spam filtering and webhooks
§ 01Setup3 steps · 60 seconds · zero config

Ship a Svelte contact form without a backend.

No SDK, no PHP, no plugin. Your form posts standard FormData to one URL — submissions land in your inbox.

STEP 01GENERATE

Get your free access key

Verify your email and your access key is generated instantly. Free for 1,000 submissions per month, forever.

Create your form

By signing up, you agree to our terms and privacy policy.

STEP 02EMBED

Drop in the Svelte code

Copy the Svelte snippet on the right and paste it into your project. Replace YOUR_ACCESS_KEY with the key from step 1.

snippetsvelte
<script>
…
STEP 03RECEIVE

Submissions land in your inbox

Hits your dashboard and email in seconds. Forward to Slack, Discord, Sheets, Notion, or any signed webhook URL.

inbox · 1 newjust now
FROM contact@yoursite.com
New Svelte form submission
Maya Iyer maya@studio71.co
Loved the new pricing page — quick question about the 4-year plan. Are usage limits per project or account-wide?
§ 02Live demosandboxed · no key required · no submission sent

Try it now — no signup, no key.

This is a styled HTML preview of what your Svelte form will look like. Submitting opens a confirmation, no real request is sent.

preview · sveltelocalhost:3000
✦ what just happened

Your Svelte form posts FormData to /api/submit. Splitforms validates the access key, runs the spam classifier, and forwards the parsed submission to your inbox plus the dashboard.

  • 14ms median round-trip from the edge.
  • Honeypot + classifier, no CAPTCHA.
  • Per-domain key locking out of the box.
REQUEST · POST /api/submit
{
  "access_key": "sk_live_4f9a_••••",
  "name":       "Maya Iyer",
  "email":      "maya@studio71.co",
  "message":    "…"
}
← 200 OK · { "success": true } · 14ms
§ 03Best practices5 rules · production-tested

How to ship this without regrets.

Five rules that make the difference between a form that works in the demo and a form that survives launch traffic.

  1. 01

    Pick one syntax era: all Svelte 4 OR all Svelte 5 in the same component. Don't mix `let` reactivity with `$state` — it works but is confusing for the next person to touch the file.

  2. 02

    Use `bind:this={formEl}` to get a stable ref to the form element, then call `formEl.reset()` after a successful submit instead of relying on `e.target.reset()` (which can be null after re-renders).

  3. 03

    Set the access key via `VITE_SPLITFORMS_KEY` (Vite) or `PUBLIC_SPLITFORMS_KEY` (SvelteKit). Lock the key to your domain in the splitforms dashboard.

  4. 04

    Add `aria-busy={status === 'loading'}` and `aria-live="polite"` to surface state changes to screen readers — Svelte's reactivity wires this automatically.

  5. 05

    For Svelte islands inside Astro, use `client:visible` so the form doesn't hydrate until the user scrolls to it.

§ 04Common gotchas in Svelte6 edge cases worth knowing

What bites people who skip the docs.

Worth a 60-second skim before you ship to production. Each one has caused a Svelte support ticket at least once.

⚠ gotcha

Svelte 5 runes vs Svelte 4 reactive let — pick one

Svelte 5 introduces $state(...) runes; Svelte 4 uses plain let status = 'idle'. They're not interchangeable in the same component. Check package.json for "svelte": "^5" and use runes accordingly. Mixing them throws a confusing 'rune used outside .svelte.js' error.

⚠ gotcha

on:submit|preventDefault becomes onsubmit={(e) => …} in Svelte 5

If you copy a Svelte 4 snippet into a Svelte 5 project, the on:submit|preventDefault modifier syntax is gone. Use onsubmit={(e) => { e.preventDefault(); … }} or migrate to a SvelteKit form action that handles preventDefault for you.

⚠ gotcha

bind:value desyncs FormData if you forget the `name` attribute

FormData reads from name="…" attributes, not Svelte's bind:value. If you bind a value but skip the name attribute, the field is silently dropped from the POST body. Always set both.

⚠ gotcha

Vite-only Svelte projects can't use $env/static/private

$env/static/private is a SvelteKit feature, not a Vite-Svelte one. In a vanilla Vite + Svelte project, use import.meta.env.VITE_SPLITFORMS_KEY (must have the VITE_ prefix or it's undefined client-side).

⚠ gotcha

Component re-mount on hot-reload resets your status state

During development, saving a .svelte file may re-mount the component mid-submission. The fetch keeps running but the UI loses its 'loading' state. Not a real bug — just don't panic when you see it in dev. Production behaves correctly.

⚠ gotcha

Reactive `$:` block fires before the value you depend on is assigned

If you write $: if (status === 'ok') resetForm(); and status = 'ok' inside an async fetch handler, the block runs synchronously after each top-level update — but resetForm() may execute before the DOM has flushed the disabled state on the submit button, causing a visible flicker where the button briefly re-enables and then the form clears. Either move the reset inside the handler after await tick(), or use Svelte 5's $effect rune which schedules properly relative to renders. Svelte 4's $: is synchronous and easy to misuse for side effects.

§ 04bNative Svelte forms…and where they break down

How Svelte handles forms without splitforms.

The shape of the problem before splitforms enters the picture — and the gap it fills for Svelte specifically.

Plain Svelte (without SvelteKit) is a compiler — there's no runtime route handler, no server, no built-in form delivery. To ship a working form natively you'd add a separate Node/Bun/Express layer, write the SMTP wiring, and operate it. SvelteKit ships form actions and use:enhance for progressive enhancement, but those just give you ergonomic ways to call your own backend; the actual email-delivery, spam-filtering, and submission-storage are still on you. Svelte 5 runes change reactivity syntax, not the operational model. Splitforms removes the entire 'add a backend' step: the runtime is one URL, hosted by us.

§ 04cAlternative integration patterns2 ways to wire it

Two ways to ship splitforms on Svelte.

Pick the pattern that matches your constraints — JS budget, key-exposure tolerance, server-side opacity. Both produce the same result.

PATTERN A

Pattern A — Svelte 4 reactive let

Classic Svelte syntax: let status = 'idle', reactive by assignment. Works in every Svelte version with a deprecation warning in Svelte 5. Single-file component, no SvelteKit required.

pattern-a.sveltesvelte14 lines
01<script>
02 let status = "idle";
03 async function onSubmit(e) {
04 status = "loading";
05 const fd = new FormData(e.target);
06 fd.append("access_key", import.meta.env.VITE_SPLITFORMS_KEY);
07 const r = await fetch("https://splitforms.com/api/submit", { method: "POST", body: fd });
08 status = (await r.json()).success ? "ok" : "err";
09 }
10</script>
11<form on:submit|preventDefault={onSubmit}>
12 <input name="email" type="email" required />
13 <button disabled={status === "loading"}>Send</button>
14</form>
PATTERN B

Pattern B — Svelte 5 runes

$state(...) for reactive variables, onsubmit={...} (no on: prefix) for events. Cleaner reactivity model, full TypeScript inference on state.

pattern-b.sveltesvelte14 lines
01<script lang="ts">
02 let status = $state<"idle" | "loading" | "ok" | "err">("idle");
03 async function onSubmit(e: SubmitEvent) {
04 e.preventDefault(); status = "loading";
05 const fd = new FormData(e.currentTarget as HTMLFormElement);
06 fd.append("access_key", import.meta.env.VITE_SPLITFORMS_KEY);
07 const r = await fetch("https://splitforms.com/api/submit", { method: "POST", body: fd });
08 status = (await r.json()).success ? "ok" : "err";
09 }
10</script>
11<form onsubmit={onSubmit}>
12 <input name="email" type="email" required />
13 <button disabled={status === "loading"}>Send</button>
14</form>
§ 04dDeployment notes for Sveltehosting · env vars · CSP

Shipping Svelte + splitforms to production.

Host-specific gotchas, env-var conventions, and the boring-but-load-bearing details for putting this on the public internet.

Vite + Svelte (non-Kit) builds a static bundle for any host. SvelteKit deploys via adapters: @sveltejs/adapter-vercel, -netlify, -cloudflare, -node, -static. The form posts client-side regardless of adapter, so the form itself works identically on each. Use VITE_SPLITFORMS_KEY for plain Vite-Svelte projects; SvelteKit uses $env/static/public from PUBLIC_SPLITFORMS_KEY. Svelte islands embedded in Astro hydrate via client:visible — the form's fetch only runs after the user scrolls to it, saving JS execution on initial load. Lock the access key to your domain.

§ 05Comparisonvs native svelte

splitforms vs native svelte.

What you get for free vs what you build, pay for, or do without.

FeatureNative Sveltesplitforms
Setup time+page.server.ts + email + spam (1 day)60 seconds
Component size~150 lines with error handling~25 lines
Spam filteringDIY honeypot or hCaptchaBuilt-in
Storage / dashboardRoll your ownIncluded free
Svelte 5 runes supportYou write it twiceSame backend, both syntaxes
CostHosting + email serviceFree (1,000/mo)
§ 06Alternative patternsvelte · 28 lines
ALTERNATIVE

Svelte 5 runes variant (modern syntax)

If your project is on Svelte 5, here's the same form using runes (`$state`) instead of plain `let`. Cleaner reactivity model, no on: prefix.

alternative.sveltesvelte28 lines
01<script lang="ts">
02 let status = $state<"idle" | "loading" | "ok" | "err">("idle");
03 let formEl: HTMLFormElement;
04
05 async function onSubmit(e: SubmitEvent) {
06 e.preventDefault();
07 status = "loading";
08 const body = new FormData(e.currentTarget as HTMLFormElement);
09 body.append("access_key", import.meta.env.VITE_SPLITFORMS_KEY);
10
11 const res = await fetch("https://splitforms.com/api/submit", { method: "POST", body });
12 const data = await res.json();
13 status = data.success ? "ok" : "err";
14 if (data.success) formEl.reset();
15 }
16</script>
17
18<form bind:this={formEl} onsubmit={onSubmit} aria-busy={status === "loading"}>
19 <input name="name" required />
20 <input name="email" type="email" required />
21 <textarea name="message" required />
22 <input type="checkbox" name="botcheck" style="display:none" tabindex="-1" />
23 <button disabled={status === "loading"}>
24 {status === "loading" ? "Sending…" : "Send"}
25 </button>
26 {#if status === "ok"}<p>Thanks!</p>{/if}
27 {#if status === "err"}<p>Error — please try again.</p>{/if}
28</form>
§ 07Questions6 answered

Things developers ask before they integrate.

Direct answers, no marketing fluff. Missing one? Email hello@splitforms.com.

01How do I add a contact form to Svelte?
Paste the .svelte snippet above into any component file (e.g. src/lib/ContactForm.svelte), import it where you need it, and replace YOUR_ACCESS_KEY. That's it.
02Does splitforms work with SvelteKit SSR / SSG?
Yes — both. The component is client-only because it uses fetch, but it hydrates inside an SSR-rendered page without issue. For server-side submission with progressive enhancement, see the dedicated /forms/sveltekit page.
03How do I handle form errors in Svelte?
Use a status variable with four states: idle, loading, ok, err. Show inline messages with {#if} blocks. The fetch returns { success, message? } — render data.message for the user when success is false.
04Can I use splitforms with SvelteKit form actions instead?
Yes — that's the SvelteKit-specific pattern (server-side, progressive enhancement, no client JS required). It's documented on the /forms/sveltekit page.
05How do I customize the success / redirect behavior?
Two options. Stay on-page with a Svelte-rendered success message (default in our snippet), or pass a hidden redirect input and use a non-AJAX form submit — splitforms 302s to that URL.
06Does this work with Svelte 4 and Svelte 5?
Yes. The hero snippet uses Svelte 4 syntax (works in 5 too with a deprecation warning); the alternative-pattern snippet uses Svelte 5 runes. Pick whichever matches your project.
✻ ✻ ✻

Ship your Svelte contact form in 60 seconds.

1,000 free submissions per month. No credit card. Lock the access key to your domains, paste the snippet, watch submissions land in your inbox.

Get free access key →Read the docs
v0.1 · founders pricing locked in · early access open