# 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.

Updated Oct 29, 2026.

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`                        |
| Email                    | 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

```svelte title="src/lib/ContactForm.svelte"
<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:

```svelte title="src/routes/contact/+page.svelte"
<script lang="ts">
  import ContactForm from '$lib/ContactForm.svelte'
</script>

<h1>Contact</h1>
<ContactForm />
```

[Create a form](https://app.nisuform.com) 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](/docs/protection/access-rules).

## Guides

- [Svelte and SvelteKit forms](https://nisuform.com/docs/frameworks/svelte)
- [Submit a form with fetch](https://nisuform.com/docs/connect/javascript)

## 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.
