splitforms.com
SVELTEKIT · CONTACT FORM

Contact form for SvelteKit apps

Use SvelteKit's progressive-enhancement form actions for a no-JS-required experience, or a plain client-side fetch for tight control over loading state. Both patterns work with the splitforms endpoint — standard FormData in, JSON out, every adapter supported.

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

What your SvelteKit 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 SvelteKit 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
SvelteKit contact form on Splitforms — drop-in form backend with spam filtering and webhooks
§ 01Setup3 steps · 60 seconds · zero config

Ship a SvelteKit 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 SvelteKit code

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

snippetsvelte
<!-- src/routes/contact/+page.svelte -->
…
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 SvelteKit 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 SvelteKit form will look like. Submitting opens a confirmation, no real request is sent.

preview · sveltekitlocalhost:3000
✦ what just happened

Your SvelteKit 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

    Default to the client-side fetch pattern (the snippet above) — fewer moving parts, works on every adapter without a server route.

  2. 02

    If you need progressive enhancement (form works without JS), use form actions with `use:enhance` — the form posts traditionally on JS-disabled clients and AJAXes when JS loads.

  3. 03

    Set the access key as `PUBLIC_SPLITFORMS_KEY` in `.env` — accessible from `$env/static/public`. Lock the key to your domain in the splitforms dashboard.

  4. 04

    After successful submit, navigate via `goto('/thanks', { invalidateAll: true })` rather than just resetting the form — gives the user clear feedback that something happened.

  5. 05

    For Cloudflare Workers / Pages adapter, never proxy through your server route. Direct browser → splitforms POST keeps you under the CPU time budget.

§ 04Common gotchas in SvelteKit6 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 SvelteKit support ticket at least once.

⚠ gotcha

Form actions need POST and the named action prefix

If you use +page.server.ts form actions, the form's action attribute must be ?/contact (or whatever you named it) — not /api/contact. Forgetting the ?/ prefix routes to a 404 because SvelteKit doesn't recognize it as an action.

⚠ gotcha

use:enhance disables your client-side handler if you don't return a callback

use:enhance without arguments uses default progressive-enhancement behavior — which calls the form action and re-renders. If you need custom logic (toast on error, etc.), return a function: use:enhance={({ formData, cancel }) => async ({ result }) => …}.

⚠ gotcha

Cloudflare adapter has a 50ms cold start budget — fetch to splitforms eats it

If you use a SvelteKit form action that proxies to splitforms.com via fetch, the round-trip eats your CF Worker time budget on cold start. Skip the proxy: have the form POST directly to splitforms.com from the client (the snippet above does this).

⚠ gotcha

$env/static/public vs $env/dynamic/public — pick the right one

$env/static/public is inlined at build time (faster, but key is in the bundle). $env/dynamic/public is read at runtime (slower, but rotatable without rebuild). For the splitforms key, static is fine if you've locked the key to your domain.

⚠ gotcha

load() functions can't access the form action result

If you try to read the splitforms response in +page.server.ts's load(), you'll find it's a separate request. Form action results are passed via the form prop on the page component, not via load. Read them as export let form.

⚠ gotcha

Named form actions need explicit `name` matching, not just URL hints

If your +page.server.ts exports actions = { contact: async () => {…}, newsletter: async () => {…} } and your form posts to ?/contact, SvelteKit dispatches by the URL query — but the submitter button must also have formaction="?/contact" if you have multiple submit buttons in one form. Browsers send only the clicked submitter's formaction, so a stray button targeting ?/newsletter will hit the wrong action even when the form's main action is ?/contact. Symptom: submissions inexplicably trigger the wrong handler. Audit every <button type="submit"> for stale formaction attributes left over from layout iteration.

§ 04bNative SvelteKit forms…and where they break down

How SvelteKit handles forms without splitforms.

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

SvelteKit's headline form story is form actions — write a default-exported actions object in +page.server.ts, and SvelteKit handles FormData parsing, progressive enhancement (use:enhance), and result passing via the form prop. It's elegant DX — but it doesn't deliver email. You still write the SMTP integration, the spam-filter logic, the database for storing submissions, the webhook fan-out. The result is a form action that's ~80% boilerplate and ~20% your business logic. Splitforms collapses the boilerplate: the form posts directly from the browser to splitforms.com, the form action becomes a thin proxy (or you skip it entirely), and the operational layer disappears.

§ 04cAlternative integration patterns2 ways to wire it

Two ways to ship splitforms on SvelteKit.

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

PATTERN A

Pattern A — client-side fetch (skip form actions)

Pure +page.svelte with a fetch handler — no +page.server.ts needed. Simpler, works on every adapter without server CPU time, no proxying. Best for Cloudflare Workers / Pages where CPU budget matters.

pattern-a.sveltesvelte14 lines
01<script>
02 let status = "idle";
03 async function onSubmit(e) {
04 status = "loading";
05 const fd = new FormData(e.currentTarget);
06 fd.append("access_key", import.meta.env.VITE_PUBLIC_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 — form action with use:enhance (no-JS support)

Server-side form action proxies to splitforms; key stays in $env/static/private. With use:enhance, the form posts traditionally without JS (full-page reload, splitforms 302 to /thanks) and AJAXes when JS loads. Maximum compatibility, slight CPU cost on the server.

pattern-b.sveltesvelte12 lines
01// +page.server.ts
02import { fail, redirect } from "@sveltejs/kit";
03import { SPLITFORMS_KEY } from "$env/static/private";
04export const actions = {
05 default: async ({ request }) => {
06 const fd = await request.formData();
07 fd.append("access_key", SPLITFORMS_KEY);
08 const r = await fetch("https://splitforms.com/api/submit", { method: "POST", body: fd });
09 if (!(await r.json()).success) return fail(400, { message: "Failed" });
10 throw redirect(303, "/thanks");
11 },
12};
§ 04dDeployment notes for SvelteKithosting · env vars · CSP

Shipping SvelteKit + splitforms to production.

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

SvelteKit's adapter system is the deployment story: @sveltejs/adapter-vercel, -netlify, -cloudflare, -cloudflare-workers, -node, -static. The form's POST is cross-origin to splitforms regardless of adapter. On Cloudflare Pages/Workers (free tier: 10ms CPU per request), avoid Pattern B — the form action's fetch round-trip eats your budget; use Pattern A. On Vercel/Netlify, both patterns work with no measurable difference. $env/static/public inlines at build time (use for client-exposed keys); $env/static/private is server-only (use for Pattern B's server-action key). Domain-lock the access key.

§ 05Comparisonvs native sveltekit

splitforms vs native sveltekit.

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

FeatureNative SvelteKitsplitforms
Setup time+page.server.ts + email + spam (1 day)60 seconds
Adapter compatibilityEach adapter has nuancesAll adapters work the same
Spam filterDIYBuilt-in
Cold start (Cloudflare)Worker CPU time eaten by fetchDirect POST, no proxy
Submission storageDatabase + Lucia authDashboard included
CostAdapter platform feesFree (1,000/mo)
§ 06Alternative patternsvelte · 37 lines
ALTERNATIVE

Form actions variant — progressive enhancement, no client JS required

If you want the form to work without JavaScript (and progressively enhance with `use:enhance`), use a SvelteKit form action that proxies to splitforms server-side. Hides the access key entirely.

alternative.sveltesvelte37 lines
01<!-- src/routes/contact/+page.server.ts -->
02import type { Actions } from "./$types";
03import { fail, redirect } from "@sveltejs/kit";
04import { SPLITFORMS_KEY } from "$env/static/private";
05
06export const actions = {
07 default: async ({ request }) => {
08 const formData = await request.formData();
09 formData.append("access_key", SPLITFORMS_KEY);
10
11 const res = await fetch("https://splitforms.com/api/submit", {
12 method: "POST",
13 body: formData,
14 });
15 const data = await res.json();
16
17 if (!data.success) {
18 return fail(400, { message: data.message ?? "Submission failed" });
19 }
20 throw redirect(303, "/thanks");
21 },
22} satisfies Actions;
23
24<!-- src/routes/contact/+page.svelte -->
25<script lang="ts">
26 import { enhance } from "$app/forms";
27 export let form;
28</script>
29
30<form method="POST" use:enhance>
31 <input name="name" required />
32 <input name="email" type="email" required />
33 <textarea name="message" required />
34 <input type="checkbox" name="botcheck" style="display:none" tabindex="-1" />
35 <button type="submit">Send</button>
36 {#if form?.message}<p class="error">{form.message}</p>{/if}
37</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 SvelteKit?
Two paths. (1) Client-side fetch (snippet above): drop a .svelte file under src/routes/contact/+page.svelte, no server file needed. (2) Form action with progressive enhancement: see the alternative-pattern snippet — adds a +page.server.ts and uses use:enhance.
02Does splitforms work with SvelteKit's form actions?
Yes. The form action just needs to POST FormData to splitforms.com from your +page.server.ts. The alternative-pattern snippet shows the full flow including error handling and redirect.
03How do I handle form errors in SvelteKit?
If using form actions, return fail(400, { message }) from the action — SvelteKit exposes it via the form prop. If using client fetch, render messages from data.message in your status state.
04Can I use splitforms with SvelteKit's `use:enhance` directive?
Yes — that's the recommended progressive-enhancement pattern. use:enhance with no arguments uses the default behavior: the form posts traditionally without JS, AJAXes with JS, and the page state updates without a full reload either way.
05How do I customize the success / redirect behavior?
From a form action: throw redirect(303, '/thanks'). From client fetch: navigate with goto('/thanks'). Or skip both and render an inline success message inside {#if status === 'ok'}.
06Does splitforms work with every SvelteKit adapter?
Yes. Vercel, Netlify, Node, Cloudflare Workers, Cloudflare Pages, static, deno — all of them. The form posts to splitforms.com from the browser, so the adapter only matters for SSR rendering of the form page itself.
✻ ✻ ✻

Ship your SvelteKit 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