Home / Guides

Add a contact form to a SvelteKit site

Published 2026-07-15

SvelteKit makes it easy to add a server route — drop a +server.js next to your page and you've got an API endpoint. But if you're shipping with adapter-static, prerendering your routes, or deploying to a host that just serves files, that option is off the table by design: there's no server process at request time to run +server.js at all. The prerendered output is HTML, CSS, and JS bundles, same as any other static site.

That's not actually a problem for a contact form. You don't need a server route to receive a POST — you need a URL that already knows how to. The pattern below is a +page.svelte with a plain HTML form (works with zero JavaScript, including on a fully static export) and an optional fetch handler layered on top for visitors who do have JS, so they get inline success/error states instead of a full page navigation.

The form: +page.svelte, progressively enhanced

<!-- src/routes/contact/+page.svelte -->
<script>
  const FORM_ENDPOINT = 'https://api.submissionbuddy.io/f/your-form'

  let status = 'idle' // 'idle' | 'submitting' | 'success' | 'error'

  async function handleSubmit(event) {
    event.preventDefault()
    status = 'submitting'

    const form = event.target
    const body = new FormData(form)

    try {
      const res = await fetch(FORM_ENDPOINT, { method: 'POST', body })
      // 202 { ok: true, submission_id: "…" } on success
      status = res.ok ? 'success' : 'error'
    } catch {
      status = 'error'
    }
  }
</script>

<form action={FORM_ENDPOINT} method="POST" on:submit={handleSubmit}>
  <label>Name
    <input type="text" name="name" required />
  </label>
  <label>Email
    <input type="email" name="email" required />
  </label>
  <label>Message
    <textarea name="message" required></textarea>
  </label>

  <!-- honeypot: hidden from real visitors, silently drops bot submissions -->
  <input type="text" name="_honeypot" style="position:absolute;left:-9999px" tabindex="-1" autocomplete="off" />

  <!-- fallback redirect for the no-JS case -->
  <input type="hidden" name="_redirect" value="/thanks" />

  <button type="submit" disabled={status === 'submitting'}>
    {status === 'submitting' ? 'Sending…' : 'Send'}
  </button>

  {#if status === 'success'}
    <p>Thanks — we'll get back to you shortly.</p>
  {:else if status === 'error'}
    <p>Something went wrong. Try again, or email us directly.</p>
  {/if}
</form>

The important detail is action={FORM_ENDPOINT} alongside on:submit={handleSubmit}. If JavaScript hasn't loaded yet — or the visitor has it disabled, or this page got prerendered and served from a CDN edge with no hydration in sight — the browser falls back to a plain HTML POST to FORM_ENDPOINT and SubmissionBuddy answers with a 303 redirect to _redirect. If JS has hydrated, event.preventDefault() takes over, the fetch call fires, and you get the inline success state without a page navigation. Same endpoint, same backend, two experiences depending on what the browser can do.

Because this is all client-side markup and a fetch call, it works identically whether the route is server-rendered, prerendered at build time, or exported fully static with adapter-static — there's no +server.js, no server load function, no API route in your SvelteKit app at all. The form talks directly to SubmissionBuddy.

Sign up free, create a form, and you'll get an endpoint that looks like https://api.submissionbuddy.io/f/your-form. There's no client library to install — fetch and a FormData object are all handleSubmit needs, and the plain action attribute handles the no-JS path for free.

Spam protection

The _honeypot field is a working trap, not a placeholder — an off-screen text input a bot's form-filler will populate but a real visitor never sees. If it arrives filled in, SubmissionBuddy drops the submission without writing it to storage or sending a notification, but still returns the same success response (202 or the _redirect flow), so there's no signal for the bot to react to.

Per-IP and per-form rate limiting runs by default on every plan. If you need to stop more sophisticated bots — the kind that clear a honeypot — paid plans add an invisible proof-of-work CAPTCHA that verifies server-side, with no third-party script and no cookies, so it won't fight with anything else you're doing client-side in the SvelteKit app.

The thank-you route

_redirect needs somewhere to land. Add a plain route:

<!-- src/routes/thanks/+page.svelte -->
<h1>Thanks</h1>
<p>We've received your message and will get back to you shortly.</p>

If you're prerendering, make sure export const prerender = true is set (either on this route or globally in src/routes/+layout.js) so it exists as a static file at build time — the same way the contact page itself needs to be prerenderable if you're on adapter-static.

What happens after the visitor hits submit

The submission is written to durable storage before SubmissionBuddy returns any response — the 202 or 303 you see in handleSubmit only comes back after that write succeeds. From there it moves through independent, retryable stages: a database write that makes it searchable in your dashboard, then an email notification to your team. If the email step has a transient failure, it retries automatically without touching the stored record.

The honest pitch

Free plan: 100 submissions a month, 2 forms, no credit card required. That covers most portfolio sites, small SaaS marketing pages, and app landing pages built with SvelteKit. Paid plans start at $8/month if you need more room. And since the whole integration is a fetch call plus a plain action attribute, there's no SDK version to track as SvelteKit or its adapters change.

Stop losing form submissions

SubmissionBuddy stores every submission durably before anything else touches it — 100 free submissions/month, no credit card, live in five minutes.

Start free