SvelteKit contact form in four minutes (no server actions required)
April 5, 2026 · 6 min read
SvelteKit is the framework I reach for when I want something fast, modern, and not exhausting. Server actions, form actions, progressive enhancement — the framework leans into <form> elements harder than almost any of its peers. It is, in a real sense, the most "plain HTML form"-friendly meta-framework I've used.
Which is exactly why most SvelteKit contact form tutorials go straight to +page.server.ts with a default action, some validation logic, an email dispatch call via Nodemailer or Resend, and a lot of error handling. That's a fine setup for a production form where you want to own every byte. For a contact form on a marketing site, it's more moving parts than you need.
Here's the version I actually ship. Four minutes, no +page.server.ts, no server actions, no email library, no dependencies.
The plan
Your <form> lives in a +page.svelte file. Its action attribute points to a hosted form backend. When submitted, the browser POSTs directly to that backend — SvelteKit's server never sees the submission. The backend handles storage, email, spam, and redirects back to your SvelteKit thank-you page.
That's it. No server runtime involved at all. Your SvelteKit app can be fully static (via adapter-static), deployed to Cloudflare Pages or GitHub Pages or an S3 bucket, and the form still works.
Step 1 — Get an endpoint
Sign up for a hosted form backend. I'll use FormTo. After creating a form you'll get a URL like:
https://api.formto.dev/f/your-form-slug
Copy it into your env variables so you don't hard-code it. Create or edit .env:
PUBLIC_CONTACT_ENDPOINT=https://api.formto.dev/f/your-form-slug
The PUBLIC_ prefix is SvelteKit's way of marking an env variable as safe to expose to the client. Form endpoint URLs are public by design (they have to be — the browser posts to them), so this is the correct prefix.
Step 2 — Build the form
Create src/routes/contact/+page.svelte:
<script lang="ts">
import { PUBLIC_CONTACT_ENDPOINT } from '$env/static/public'
</script>
<svelte:head>
<title>Contact us</title>
</svelte:head>
<main class="mx-auto max-w-lg px-6 py-16">
<h1 class="text-4xl font-bold mb-4">Say hi</h1>
<p class="text-gray-600 mb-8">
We reply within a day. Promise.
</p>
<form
method="POST"
action={PUBLIC_CONTACT_ENDPOINT}
class="space-y-4"
>
<label class="block">
<span class="text-sm font-medium">Name</span>
<input
type="text"
name="name"
required
class="mt-1 w-full border rounded-md px-3 py-2"
/>
</label>
<label class="block">
<span class="text-sm font-medium">Email</span>
<input
type="email"
name="email"
required
class="mt-1 w-full border rounded-md px-3 py-2"
/>
</label>
<label class="block">
<span class="text-sm font-medium">Message</span>
<textarea
name="message"
rows="5"
required
class="mt-1 w-full border rounded-md px-3 py-2"
/>
</label>
<!-- honeypot -->
<input
type="text"
name="website"
tabindex={-1}
autocomplete="off"
class="hidden"
aria-hidden="true"
/>
<input type="hidden" name="_redirect" value="https://yoursite.com/thanks" />
<button
type="submit"
class="px-5 py-2 bg-black text-white rounded-md hover:bg-gray-800"
>
Send message
</button>
</form>
</main>
That's the entire contact page. No <script> logic beyond the env import. No state, no validation hooks, no loading spinner. SvelteKit ships it as a static HTML page because nothing on it requires hydration.
Step 3 — The thank-you page
Create src/routes/thanks/+page.svelte:
<svelte:head>
<title>Thanks</title>
</svelte:head>
<main class="mx-auto max-w-lg px-6 py-24 text-center">
<h1 class="text-4xl font-bold mb-2">Got it.</h1>
<p class="text-gray-600 mb-6">We'll reply within a day or two.</p>
<a href="/" class="text-blue-600 underline">← Back home</a>
</main>
That's the whole flow. Commit, deploy, done.
Why not use form actions?
This is a fair question, because SvelteKit's form actions are genuinely lovely. They give you progressive enhancement, typed return values, the use:enhance directive, and a natural place to validate input before it leaves your app.
For a contact form specifically, here's the tradeoff.
What form actions give you:
- Server-side validation with typed results
- Full control over response shape
- The ability to render error messages inline without a page navigation
- Progressive enhancement via
use:enhance
What form actions cost:
- A server runtime on every request (goodbye to
adapter-static) - The entire email/spam/storage/retry pipeline becomes your responsibility
- You write 50–150 lines of
+page.server.tscode for each form - You manage Resend (or similar) API keys, rate limits, and bounce handling
- You build your own spam filtering or glue in Akismet
If you're building a complex form with conditional logic, multi-step flows, or real-time validation, form actions are worth it. For a contact form — three fields and a button — they are expensive convenience. The hosted endpoint approach keeps your site static, moves the boring parts to a vendor, and ships in one-tenth the code.
I'm not against form actions. I use them in SvelteKit apps all the time. I just don't use them for contact forms, because contact forms are the specific problem hosted endpoints were built to solve.
The use:enhance upgrade, if you want it later
If you want fancier UX later — inline error messages, a loading spinner, no full-page redirect — you can wrap the form in SvelteKit's enhance action and intercept the submit to POST via fetch:
<script lang="ts">
import { PUBLIC_CONTACT_ENDPOINT } from '$env/static/public'
import { goto } from '$app/navigation'
let loading = false
let error = ''
async function handleSubmit(event: SubmitEvent) {
event.preventDefault()
loading = true
error = ''
const form = event.target as HTMLFormElement
const data = new FormData(form)
try {
const response = await fetch(PUBLIC_CONTACT_ENDPOINT, {
method: 'POST',
body: data,
})
if (!response.ok) throw new Error('Submission failed')
goto('/thanks')
} catch (e) {
error = 'Something went wrong. Please try again.'
loading = false
}
}
</script>
<form on:submit={handleSubmit}>
<!-- ...same fields as before... -->
<button type="submit" disabled={loading}>
{loading ? 'Sending...' : 'Send message'}
</button>
{#if error}
<p class="text-red-600">{error}</p>
{/if}
</form>
I'd resist doing this until you actually have the plain version live and shipping submissions. Most of the time, the extra UX polish is not worth the complexity — and it definitely isn't worth it before you know the form is converting at all.
The SvelteKit-specific gotcha
SvelteKit's default handleEvent logic treats form POSTs specially when the action is a relative path (it routes them to your server actions). When the action is an absolute URL (like ours), SvelteKit gets out of the way and lets the browser do a native navigation POST to the external URL.
This is the right behavior and it's why the setup above works without any additional configuration. But it means: don't accidentally add a leading slash to your action URL. action={PUBLIC_CONTACT_ENDPOINT} works; action={'/' + PUBLIC_CONTACT_ENDPOINT} would fail in a confusing way. Use the full https:// URL, always.
Static adapter compatibility
If you want to deploy your SvelteKit site as static HTML (to Cloudflare Pages, GitHub Pages, an S3 bucket, or any CDN), switch to adapter-static in svelte.config.js:
import adapter from '@sveltejs/adapter-static'
export default {
kit: {
adapter: adapter({
fallback: '404.html',
}),
},
}
And add export const prerender = true to your +layout.ts:
export const prerender = true
Your contact form will keep working perfectly, because the form never required a server in the first place. This is the configuration I use on most small SvelteKit sites.
Create a free form, put the URL in your .env file, deploy. Your SvelteKit site has a working contact form that survives being fully static.
If you're running other frameworks alongside SvelteKit, the same pattern works on Next.js, Astro, Nuxt, Hugo, Eleventy, Webflow, Framer, and GitHub Pages.
More posts