# SvelteKit

> A prerendered SvelteKit page whose form posts straight to Ikiform, enhanced with fetch once JavaScript loads. Works with adapter-static.

This page needs no server: the form's `action` is your Ikiform endpoint, so it works on `adapter-static` and any static host. Without JavaScript it posts and redirects like plain HTML; with JavaScript it posts with `fetch` and shows the result in place. To post through a form action with a secret key instead, see [Svelte](https://ikiform.com/docs/guides/svelte.md#sveltekit).

## Why not use:enhance

SvelteKit's `use:enhance` only works with `method="POST"` forms that point to actions defined in a `+page.server.js` file. Ikiform is an outside URL, so the page uses a small `onsubmit` handler instead. The progressive enhancement is the same: the form works first, the script improves it.

## Set it up

### Step 1: Add public environment variables

`.env`:

```bash
PUBLIC_IKIFORM_FORM_ID=frm_xxxxxxxxxxxxxxxx
PUBLIC_SITE_URL=https://example.com
```

`PUBLIC_SITE_URL` is for the no-JavaScript redirect. While prerendering, `page.url.origin` is `http://sveltekit-prerender`, not your domain, so the redirect can't be built from it.

### Step 2: Prerender the page

`src/routes/contact/+page.js`:

```js
export const prerender = true;
```

### Step 3: Add the form

`src/routes/contact/+page.svelte`:

```svelte
<script>
  import { PUBLIC_IKIFORM_FORM_ID, PUBLIC_SITE_URL } from "$env/static/public";

  const endpoint = `https://ikiform.com/f/${PUBLIC_IKIFORM_FORM_ID}`;

  let status = $state("idle");
  let errors = $state({});
  let message = $state("");

  async function onsubmit(event) {
    event.preventDefault();
    status = "sending";
    try {
      const response = await fetch(endpoint, {
        method: "POST",
        headers: { Accept: "application/json" },
        body: new FormData(event.currentTarget),
      });
      const result = await response.json();
      if (result.ok) {
        status = "sent";
        return;
      }
      errors = result.error.fields ?? {};
      message = result.error.message;
    } catch {
      message = "Couldn't reach the server. Try again.";
    }
    status = "error";
  }
</script>

{#if status === "sent"}
  <p>Thanks, we got it.</p>
{:else}
  <form action={endpoint} method="POST" {onsubmit}>
    <label>
      Email
      <input name="email" type="email" autocomplete="email" required aria-invalid={errors.email ? "true" : undefined} />
    </label>
    {#if errors.email}<p>{errors.email[0]}</p>{/if}
    <label>
      Message
      <textarea name="message" rows="5"></textarea>
    </label>
    <input type="hidden" name="_redirect" value={`${PUBLIC_SITE_URL}/contact/thanks`} />
    <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
    {#if status === "error" && !errors.email}<p role="alert">{message}</p>{/if}
    <button disabled={status === "sending"}>{status === "sending" ? "Sending" : "Send"}</button>
  </form>
{/if}
```

Add a `src/routes/contact/thanks/+page.svelte` for the redirect target.

## If you use SvelteKit's CSP

With `kit.csp` configured, allow Ikiform for both the `fetch` post and the plain form post. Otherwise the browser blocks one or the other.

`svelte.config.js`:

```js
const config = {
  kit: {
    csp: {
      directives: {
        "connect-src": ["self", "https://ikiform.com"],
        "form-action": ["self", "https://ikiform.com"],
      },
    },
  },
};

export default config;
```

## Allowed domains

List production plus your preview hosts.

| Where the form runs | Add to Allowed domains |
| --- | --- |
| `vite dev` | `localhost`. It matches `http://localhost:5173`: Ikiform compares the host name only, never the port. |
| Your domain | `example.com`, which also covers `www.example.com` |
| Vercel | `your-project.vercel.app`. Preview URLs such as `your-project-git-branch-team.vercel.app` don't match it; add the exact preview host while you test one. |
| Cloudflare Pages | `your-project.pages.dev`. Preview URLs such as `abc123.your-project.pages.dev` are subdomains of it, so they already match. |
| Netlify | `your-site.netlify.app`. Deploy previews (`deploy-preview-12--your-site.netlify.app`) and branch deploys (`branch--your-site.netlify.app`) are not subdomains of it, so add the exact preview host while you test one. |

- Never add `vercel.app`, `pages.dev` or `netlify.app` on its own. Every site on that host would match, including other people's.

## Test checklist

1. Run the dev server with `localhost` in allowed domains and submit. The thanks message should replace the form.
2. Run `vite build` and check the output contains `contact.html` with the form already in it.
3. Turn JavaScript off, reload and submit. On your `https:` domain you should land on `/contact/thanks`.
4. Submit from a preview deploy and confirm it isn't marked spam.

## Troubleshooting

| What you see | Why | Fix |
| --- | --- | --- |
| The build fails saying `PUBLIC_IKIFORM_FORM_ID` isn't exported by `$env/static/public` | The variable isn't set where the build runs | Add it to `.env` locally and to your host's build settings. |
| Nothing happens on submit, and the console shows a CORS error about the `x-sveltekit-action` header | `use:enhance` on a form that posts outside your app | Remove it and keep the `onsubmit` handler. |
| The browser refuses to send the form because of `form-action` or `connect-src` | `kit.csp` doesn't list Ikiform | Add `https://ikiform.com` to both directives. |
| Without JavaScript you land on Ikiform's thank-you page | `PUBLIC_SITE_URL` is `http:`, or the domain isn't in allowed domains | Use your `https:` address and add the domain. |
| Submissions arrive under the spam filter with `origin_mismatch` | The page that posted isn't on the form's allowed domains | Add the host from the table above. With **Reject** chosen, the same posts get `403 origin_not_allowed` instead. |

## Related

- [Svelte](https://ikiform.com/docs/guides/svelte.md): A Svelte 5 component and a server action.
- [Astro](https://ikiform.com/docs/guides/astro.md): A zero-JavaScript form, optionally enhanced.
- [Allowed domains](https://ikiform.com/docs/spam.md#allowed-domains): How origins are matched, and what a mismatch does.
- [Errors](https://ikiform.com/docs/errors.md): Every error code, with its cause and fix.
