Three ways to handle Svelte form submission in 2026
A Svelte form submissioncan take three structural shapes, and the right choice depends on whether the form's data goes to your own database or just to your inbox. The full options:
- SvelteKit form actions. Server-side handlers in
+page.server.tswith progressive enhancement viause:enhance. Best when the form writes to your own database or queues a background job. - API endpoints. Custom
+server.tsroutes that handle the POST and return JSON. Best when the form is part of a web app with its own auth flow. - Hosted form backend. The form's
actionattribute points at an external service (splitforms, Formspree, Web3Forms) that handles email, storage and spam filtering. Best for marketing-page contact forms, lead capture, feedback widgets — anywhere you don't need server-side business logic.
This guide covers option three because it's the simplest path for the most common Svelte form use case: I want submissions in my inbox without writing a backend. The page can stay statically prerendered, deploy to a free static host, and ship in five minutes. We'll use splitforms as the Svelte form backend; the same code patterns work on SvelteKit.
Svelte 5 contact form with runes
Create src/lib/components/ContactForm.svelte. The component uses Svelte 5 runes for the form state and a single handleSubmit that posts to splitforms:
<!-- src/lib/components/ContactForm.svelte -->
<script lang="ts">
import { PUBLIC_SPLITFORMS_KEY } from '$env/static/public';
type Status = 'idle' | 'submitting' | 'success' | 'error';
let status = $state<Status>('idle');
let errorMsg = $state('');
const FORM_URL = 'https://splitforms.com/api/submit';
async function handleSubmit(event: SubmitEvent) {
event.preventDefault();
const form = event.currentTarget as HTMLFormElement;
status = 'submitting';
errorMsg = '';
try {
const res = await fetch(form.action, {
method: 'POST',
body: new FormData(form),
headers: { Accept: 'application/json' },
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data?.message ?? `HTTP ${res.status}`);
}
status = 'success';
form.reset();
} catch (err) {
status = 'error';
errorMsg = (err as Error).message;
}
}
</script>
<form
action={FORM_URL}
method="POST"
onsubmit={handleSubmit}
class="contact"
>
<input type="hidden" name="access_key" value={PUBLIC_SPLITFORMS_KEY} />
<label>
Your name
<input type="text" name="name" required autocomplete="name" />
</label>
<label>
Your email
<input type="email" name="email" required autocomplete="email" />
</label>
<label>
Your message
<textarea name="message" rows="5" required></textarea>
</label>
<button type="submit" disabled={status === 'submitting'}>
{status === 'submitting' ? 'Sending…' : 'Send message'}
</button>
{#if status === 'success'}
<p role="status" class="ok">Thanks — we received your message.</p>
{:else if status === 'error'}
<p role="alert" class="err">Couldn't send: {errorMsg}. Please try again.</p>
{/if}
</form>
<style>
.contact { display: grid; gap: 14px; max-width: 32rem; }
.contact label { display: grid; gap: 6px; font-size: 14px; }
.contact input, .contact textarea {
padding: 10px 12px; border: 1px solid #d4d4d8;
border-radius: 8px; font: inherit;
}
.contact button {
padding: 12px 18px; border: none; border-radius: 8px;
background: #ff4f00; color: #fff; font-weight: 600; cursor: pointer;
}
.contact button:disabled { opacity: 0.6; cursor: not-allowed; }
.ok { color: #14532d; }
.err { color: #991b1b; }
</style>Add the access key to .env at the project root:
# .env
PUBLIC_SPLITFORMS_KEY=sf_pk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxDrop the component into a page and you're done:
<!-- src/routes/contact/+page.svelte -->
<script lang="ts">
import ContactForm from '$lib/components/ContactForm.svelte';
</script>
<svelte:head>
<title>Contact us</title>
</svelte:head>
<main>
<h1>Contact us</h1>
<p>We respond within one business day.</p>
<ContactForm />
</main>Mark the page as prerenderable so SvelteKit emits static HTML at build time:
// src/routes/contact/+page.ts
export const prerender = true;The SvelteKit form-actions alternative
For comparison, here's the same form using SvelteKit form actions. The advantage: the access key stays server-side. The cost: you need an SSR adapter (the page can't prerender) and the action handler runs on every submission, adding ~30ms of cold-start latency on serverless.
// src/routes/contact/+page.server.ts
import { fail, type Actions } from '@sveltejs/kit';
import { SPLITFORMS_KEY } from '$env/static/private';
export const actions: Actions = {
default: async ({ request }) => {
const form = await request.formData();
const name = String(form.get('name') ?? '');
const email = String(form.get('email') ?? '');
const message = String(form.get('message') ?? '');
if (!name || !email || !message) {
return fail(400, { error: 'All fields are required.' });
}
const body = new URLSearchParams({
access_key: SPLITFORMS_KEY,
name, email, message,
});
const res = await fetch('https://splitforms.com/api/submit', {
method: 'POST',
body,
headers: { Accept: 'application/json' },
});
if (!res.ok) return fail(500, { error: 'Submission failed.' });
return { success: true };
},
};<!-- src/routes/contact/+page.svelte -->
<script lang="ts">
import { enhance } from '$app/forms';
let { form } = $props();
</script>
<form method="POST" use:enhance>
<label>Name <input name="name" required /></label>
<label>Email <input name="email" type="email" required /></label>
<label>Message <textarea name="message" required></textarea></label>
<button type="submit">Send</button>
{#if form?.success}<p>Thanks!</p>{/if}
{#if form?.error}<p>{form.error}</p>{/if}
</form>When to pick this: you already have an SSR adapter for other reasons, or company policy requires the access key stay server-side. Otherwise, the static prerendered version above ships faster and costs less to host.
Spam protection in a Svelte form
Svelte's reactivity makes it tempting to add JS-only spam tricks (timing checks, mouse-movement detection), but the simplest and most effective approach is the same on every framework: a honeypot field. Bots fill every input they find; humans never see the hidden field. splitforms drops any submission with a non-empty honeypot value:
<form action={FORM_URL} method="POST" onsubmit={handleSubmit}>
<input type="hidden" name="access_key" value={PUBLIC_SPLITFORMS_KEY} />
<!-- Honeypot. Bots fill it; humans never see it. -->
<div aria-hidden="true" style="position:absolute;left:-9999px">
<input type="text" name="website" tabindex="-1" autocomplete="off" />
</div>
<label>Name <input name="name" required /></label>
<label>Email <input name="email" type="email" required /></label>
<label>Message <textarea name="message" required></textarea></label>
<button type="submit">Send</button>
</form>For sites that get aggressive bot traffic, layer Cloudflare Turnstile on top — the widget works on prerendered Svelte pages because validation happens client-side and splitforms re-checks the token on the backend. See honeypot vs reCAPTCHA for benchmarks and the layered spam guide.
File upload from a Svelte form
Add enctype="multipart/form-data" to the form and a file input. The submit handler stays identical because FormData already handles binary uploads natively:
<form
action={FORM_URL}
method="POST"
enctype="multipart/form-data"
onsubmit={handleSubmit}
>
<input type="hidden" name="access_key" value={PUBLIC_SPLITFORMS_KEY} />
<label>Name <input name="name" required /></label>
<label>Email <input name="email" type="email" required /></label>
<label>Message <textarea name="message" required></textarea></label>
<label>
Attach a screenshot (optional)
<input type="file" name="screenshot"
accept="image/png,image/jpeg,application/pdf" />
</label>
<button type="submit">Send with attachment</button>
</form>The most common bug here is forgetting enctype="multipart/form-data" — without it, the browser sends the filename as a string and silently drops the actual file bytes. Always set enctype on file-upload forms.
splitforms vs other Svelte form backends
Most hosted form backends work with Svelte (the integration is just an HTTP POST), but their pricing and feature sets differ. Quick scan in 2026:
- Formspree. 50 submissions/month free, $10/mo for 1,000. Mature, but expensive past the free tier. splitforms vs Formspree.
- Web3Forms. Unlimited submissions on the free tier but capped per-day, branding on the success page on free. splitforms vs Web3Forms.
- Getform. 50/month free, $19/mo for the basic plan. Strong dashboard but the free tier is restrictive.
- splitforms. 1,000/month free, $5/mo for 5,000, $59 for 4 years (15,000/mo for 48 months). Webhooks free.
For a deeper review, see the 2026 ranked free form backends and the head-to-head comparison.
Ship the form
Sign up at /login for a free access key, or generate one without signup at /free-contact-form. Drop it into the Svelte component above and your contact form ships in under five minutes. The same component pattern works on Svelte 4, Svelte 5, SvelteKit 2 and any Vite + Svelte toolchain.
Related: contact forms on static sites, HTML form action complete guide, and the splitforms docs for webhooks, redirects and file uploads. Pricing details at /pricing.
FAQ
Should I use SvelteKit form actions or a hosted form backend?
SvelteKit form actions are great when you have your own database and want server-side validation co-located with the page. For a contact form that just needs to land in your inbox, a hosted form backend is faster: no Node host required (the page can stay static-prerendered), no SMTP credentials, no spam filtering to write yourself. Use form actions for app forms (signup, settings); use a hosted backend for marketing-page contact forms.
Does this work with Svelte 5 runes?
Yes. The component code uses `$state` and `$derived` runes from Svelte 5. The submission logic — fetching splitforms.com/api/submit with FormData — is the same as Svelte 4. If you're still on Svelte 4, swap `let count = $state(0)` for `let count = 0` and the rest of the example works unchanged.
Can I prerender the page if it has a contact form?
Yes. Because the form posts directly to splitforms (not to a server endpoint on your own host), the page can be marked `export const prerender = true` in `+page.ts`. SvelteKit emits static HTML at build time, the form's action attribute points at an external URL, and the entire flow works without an SSR adapter. This is the cheapest way to host a SvelteKit marketing site.
How do I show a custom thank-you message instead of redirecting?
Intercept the submit event with `on:submit|preventDefault`, fetch the splitforms endpoint with `Accept: application/json`, and toggle a state variable on success. The example in the post shows the full pattern — the form keeps a real action URL so it still works without JavaScript.
Where do I keep the access key in a SvelteKit project?
Put it in `.env` as `PUBLIC_SPLITFORMS_KEY=...` and import it via `$env/static/public`. The PUBLIC_ prefix is what tells SvelteKit it's safe to ship to the client bundle. If you want it kept server-side, use a SvelteKit form action and read it from `$env/static/private` — never from `process.env` directly, which won't work in Cloudflare Workers or Vercel Edge.
Does this support file uploads?
Yes. Add `enctype="multipart/form-data"` to the form, an `<input type="file" name="attachment" />`, and submit with `new FormData(form)` as the fetch body. Don't try to JSON-encode files — JSON can't carry binary bytes. splitforms accepts files up to 5MB on the free plan.
What's the difference between use:enhance and a manual fetch?
`use:enhance` is SvelteKit-specific — it progressively enhances a form action without a custom submit handler, automatically updating the form's `$page.form` state. It only works with form actions, not with arbitrary external URLs like splitforms. For posting to splitforms directly, write a small `on:submit` handler with fetch — the equivalent boilerplate is about 10 lines.