SvelteKit contact forms on a static site
Send Svelte and SvelteKit forms to your email without form actions or a server adapter. A Svelte 5 component that works with adapter-static and without JavaScript.
Free for 100 submissions a month, no credit card. Updated
SvelteKit's form actions are the obvious way to handle a form, but they run on the server. Build with adapter-static and they're gone. Posting to a Nisuform endpoint works with any adapter, because the browser sends the form straight to the endpoint, which filters spam, stores the submission and emails you.
| SvelteKit form actions | Nisuform endpoint | |
|---|---|---|
| Server adapter | Required, like adapter-node or adapter-vercel | Not needed, works with adapter-static |
| You add a provider and an API key | Included | |
| Spam filtering | You write it | Honeypot, fill-time check, rate limits, Turnstile or reCAPTCHA |
| Stored submissions | You add a database | Searchable inbox with CSV export |
| Works without JavaScript | Yes | Yes |
Keep form actions for work your server has to do, like creating an account. A contact form only has to reach you.
The component
<script lang="ts">
const endpoint = 'https://api.nisuform.com/s/YOUR_FORM_KEY'
const shownAt = Date.now()
let status = $state<'idle' | 'sending' | 'sent' | 'error'>('idle')
let error = $state('')
async function submit(event: SubmitEvent) {
event.preventDefault()
const data = new FormData(event.currentTarget as HTMLFormElement)
data.set('_elapsed', String(Date.now() - shownAt))
status = 'sending'
try {
const response = await fetch(endpoint, {
method: 'POST',
headers: { Accept: 'application/json' },
body: data,
})
const result = await response.json()
if (result.ok) {
status = 'sent'
} else {
error = result.message
status = 'error'
}
} catch {
error = 'Something went wrong. Please try again.'
status = 'error'
}
}
</script>
{#if status === 'sent'}
<p role="status">Thanks! We got your message.</p>
{:else}
<form action={endpoint} method="POST" onsubmit={submit}>
<label>
Email
<input name="email" type="email" autocomplete="email" required />
</label>
<label>
Message
<textarea name="message" rows="5" required></textarea>
</label>
<input type="text" name="_gotcha" tabindex="-1" autocomplete="off" style="display: none" />
<button type="submit" disabled={status === 'sending'}>
{status === 'sending' ? 'Sending...' : 'Send'}
</button>
{#if status === 'error'}
<p role="alert">{error}</p>
{/if}
</form>
{/if}With action and method set, this is a working HTML form before any JavaScript runs. Once the component hydrates, onsubmit takes over and the visitor sees the result without leaving the page. Render it from any route:
<script lang="ts">
import ContactForm from '$lib/ContactForm.svelte'
</script>
<h1>Contact</h1>
<ContactForm />Create a form and paste the key from its Setup tab into endpoint. Every input with a name is saved, even ones you never added in the dashboard. Run npm run dev, wait two seconds and send a test.
Any adapter, or none
The component works the same however the page is built. With adapter-static or export const prerender = true, the page is a file and the form posts from the browser, so GitHub Pages, Cloudflare Pages and Netlify all work. With adapter-node, adapter-vercel or adapter-cloudflare it behaves the same, and you can still use form actions on other routes.
Plain Svelte in a Vite app or an Astro island uses the same file. Import it from wherever you keep it instead of $lib.
When the site is live, add your domain and http://localhost:5173 to the form's allowed websites.
What you get with Nisuform
Free covers 100 submissions a month across 3 forms. Pro is $10 a month for 5,000 submissions and adds the features marked Pro.
Spam filtering on every plan
A honeypot, a fill-time check, rate limits and duplicate checks run on every submission, with optional Cloudflare Turnstile or Google reCAPTCHA. Spam never counts toward your limit.
An email for every submission
You get an email within seconds of each submission. Reply-to is set to the visitor, so you can answer from your mail app.
Every submission kept
Search, star and export to CSV. Submissions stay until you delete them, so a missed email never loses a message.
File uploadsPro
Up to 5 images or PDFs per submission, stored privately and shared through links that expire.
Slack, Discord, Sheets and webhooksPro
Send each submission to Slack, Discord, Telegram or Google Sheets, or anywhere else with signed webhooks, Zapier or Make.
Auto-repliesPro
Send each visitor a confirmation in your own words, from your own sender name.
Svelte guides
Frequently asked questions
Can I use SvelteKit form actions with adapter-static?
No. Form actions run on the server, and adapter-static builds a site with no server. A form that posts to a form backend endpoint works on a static site, because the browser sends it straight to the endpoint.
Do I need form actions to send email from SvelteKit?
No. Form actions are for when your server does something with each submission. A contact form can post to a form backend instead, without any server code.
Does the form work without JavaScript?
Yes. The endpoint is the form's action, so before JavaScript loads, or if it never does, the browser posts the form and the visitor sees the thank-you page. With JavaScript, the component submits in place.
Does it work with Svelte 4?
The example uses Svelte 5 runes and the onsubmit attribute. In Svelte 4, use let variables and on:submit|preventDefault. The request is the same.
Is the form key safe in client code?
Yes. It only accepts new submissions, and nobody can read your submissions or change the form with it. Add your domains to the form's allowed websites to stop other sites from posting to it.