SvelteKit contact form in four minutes (no server actions required)

April 5, 2026 · 6 min read

Laptop with code editor — SvelteKit contact form setup

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.ts code 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.

← All posts