Add a contact form to a SvelteKit site
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.