> ## Documentation Index
> Fetch the complete documentation index at: https://formcarry.com/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Svelte

> Svelte sends a form to formcarry with one fetch call from the component.

It keeps the fields in Svelte 5 runes, posts them as JSON and shows formcarry's answer, and the same request carries files, a spam blocker token and validation errors, in the browser or from a SvelteKit form action.

## Prerequisites

Before you start, you need:

* A formcarry account. [Sign up](https://app.formcarry.com/register) is free.
* A form in the [dashboard](https://app.formcarry.com). Its endpoint is on the form's Setup page. The examples use `https://formcarry.com/s/AbC123xyz`; put yours in its place.

## The component

To send a form from Svelte, keep the fields in state, post them as JSON, and send `Accept: application/json` so the answer comes back as JSON:

```svelte theme={null}
<script>
  let fields = $state({ name: "", email: "", message: "" })
  let status = $state("idle")
  let error = $state("")

  async function submit(e) {
    e.preventDefault()
    status = "sending"
    const res = await fetch("https://formcarry.com/s/AbC123xyz", {
      method: "POST",
      headers: { "Accept": "application/json", "Content-Type": "application/json" },
      body: JSON.stringify(fields),
    })
    const data = await res.json()
    if (data.code === 200) {
      status = "sent"
    } else {
      status = "error"
      error = data.message
    }
  }
</script>

{#if status === "sent"}
  <p>Thanks, we got your message.</p>
{:else}
  <form onsubmit={submit}>
    <input name="name" bind:value={fields.name} />
    <input name="email" type="email" bind:value={fields.email} />
    <textarea name="message" bind:value={fields.message}></textarea>
    <button type="submit" disabled={status === "sending"}>Send</button>
    {#if error}<p>{error}</p>{/if}
  </form>
{/if}
```

The keys of the JSON body are the field names formcarry stores.

The `email` key is the visitor's address, so it becomes the reply to address of your notification email and the recipient of the auto response. A stored submission answers with `code: 200`. A refused one answers with the reason in `message`, see [What every form needs](/docs/what-every-form-needs).

Send `Accept: application/json` rather than `Content-Type` alone, otherwise the answer is the thank you page's HTML and `res.json()` throws.

## Files

To send files, build a `FormData` from the form element and leave the content type to the browser:

```svelte theme={null}
<script>
  async function submit(e) {
    e.preventDefault()
    const res = await fetch("https://formcarry.com/s/AbC123xyz", {
      method: "POST",
      headers: { "Accept": "application/json" },
      body: new FormData(e.currentTarget),
    })
    const data = await res.json()
  }
</script>

<form onsubmit={submit}>
  <input name="email" type="email" />
  <input name="attachment" type="file" />
  <button type="submit">Send</button>
</form>
```

`FormData` reads the values from the elements, so every input needs a `name`, the file input too. Send `Accept` and nothing else rather than adding `Content-Type` yourself, otherwise the multipart boundary is missing and the upload fails.

For several files, bind the input's `files`, build the `FormData` yourself and append each file under its own name:

```svelte theme={null}
<script>
  let files = $state()

  async function submit(e) {
    e.preventDefault()
    const data = new FormData()
    data.append("email", fields.email)
    Array.from(files ?? []).forEach((file, i) => data.append(`file-${i}`, file, file.name))
    const res = await fetch("https://formcarry.com/s/AbC123xyz", {
      method: "POST",
      headers: { "Accept": "application/json" },
      body: data,
    })
  }
</script>

<input type="file" multiple bind:files={files} />
```

For small files you can stay with JSON and send the file as a data URL. Read it with `FileReader`, and give each file its own key, never an array:

```svelte theme={null}
let attachment = $state()

const toDataUrl = (file) =>
  new Promise((resolve, reject) => {
    const reader = new FileReader()
    reader.onload = () => resolve(reader.result)
    reader.onerror = reject
    reader.readAsDataURL(file)
  })

body: JSON.stringify({ ...fields, attachment: await toDataUrl(attachment[0]) })

<input type="file" bind:files={attachment} />
```

Use data URLs for small files. Using them with larger files bloats the request by about a third and reaches the 50 MB limit sooner.

<Note>
  Files are stored on paid plans only. Free plans store the rest of the submission without them.
</Note>

## SvelteKit

To keep the form working before the JavaScript loads, keep `action` and `method="POST"` on the form and read the endpoint from `e.currentTarget.action` in the handler:

```svelte theme={null}
<script>
  async function submit(e) {
    e.preventDefault()
    const res = await fetch(e.currentTarget.action, {
      method: "POST",
      headers: { "Accept": "application/json", "Content-Type": "application/json" },
      body: JSON.stringify(fields),
    })
    const data = await res.json()
  }
</script>

<form action="https://formcarry.com/s/AbC123xyz" method="POST" onsubmit={submit}>
```

Until the JavaScript loads, the browser posts the form itself and the visitor lands on formcarry's thank you page or your redirect. Write this handler rather than `use:enhance`, otherwise SvelteKit reads formcarry's answer as one of its own form action results and `form` in your page never updates.

To post from the server instead, write a form action in `+page.server.js`:

```js theme={null}
import { fail } from "@sveltejs/kit"

export const actions = {
  default: async ({ request }) => {
    const res = await fetch("https://formcarry.com/s/AbC123xyz", {
      method: "POST",
      headers: { Accept: "application/json" },
      body: await request.formData(),
    })
    const data = await res.json()
    if (data.code !== 200) return fail(data.code, { message: data.message })
    return { sent: true }
  },
}
```

In `+page.svelte`, post with `method="POST"` and no `action`, and read the action's answer from `form`:

```svelte theme={null}
<script>
  import { enhance } from "$app/forms"

  let { form } = $props()
</script>

{#if form?.sent}
  <p>Thanks, we got your message.</p>
{:else}
  <form method="POST" use:enhance>
    <input name="name" />
    <input name="email" type="email" />
    <textarea name="message"></textarea>
    <button type="submit">Send</button>
    {#if form?.message}<p>{form.message}</p>{/if}
  </form>
{/if}
```

A refusal arrives as `form.message`. `use:enhance` belongs here, on a form that posts to your own action, and keeps the page from reloading on each submission.

Every submission now leaves your server, so all your visitors share its IP address and the 1 submission per 15 seconds per form per IP address limit applies to all of them together. The spam blocker token still has to come from the browser, so its widget stays in the form.

<Tip>
  We recommend posting from the browser for public forms. The limit then applies to each visitor on their own, giving them their own 15-second window.
</Tip>

## Spam blocker

To add a challenge, render the vendor's widget, keep its token in state, send it under the name formcarry reads, and paste the secret key into the form's settings under Form Security:

| Challenge           | Token field             |
| ------------------- | ----------------------- |
| reCAPTCHA v2 and v3 | `g-recaptcha-response`  |
| hCaptcha            | `h-captcha-response`    |
| Turnstile           | `cf-turnstile-response` |

With Turnstile, load its script before the component mounts, then render the widget into an element you bind:

```svelte theme={null}
<script>
  import { onMount } from "svelte"

  let token = $state("")
  let widget

  onMount(() => {
    turnstile.render(widget, { sitekey: SITE_KEY, callback: (t) => (token = t) })
  })
</script>

<div bind:this={widget}></div>

body: JSON.stringify({ ...fields, "cf-turnstile-response": token })
```

reCAPTCHA v2 and hCaptcha render the same way, through `grecaptcha.render` and `hcaptcha.render`. With v3 there is no widget. Ask for the token when the visitor submits:

```svelte theme={null}
const token = await grecaptcha.execute(SITE_KEY, { action: "submit" })

body: JSON.stringify({ ...fields, "g-recaptcha-response": token })
```

Refuse to send while the token is empty rather than posting anyway, otherwise formcarry answers `403` (reCAPTCHA) or `400` (the others). The guard goes at the top of the handler:

```svelte theme={null}
if (!token) {
  error = "Complete the challenge, then send again."
  return
}
```

Add `localhost` to the challenge's allowed domains while you test.

## Validation errors

A `422` carries `errors`, one entry per failing field, each with a `message`. To show each one next to its field, add a branch to the handler:

```svelte theme={null}
let fieldErrors = $state({})

if (data.code === 200) {
  status = "sent"
} else if (data.code === 422) {
  status = "error"
  for (const field in data.errors) fieldErrors[field] = data.errors[field].message
} else {
  status = "error"
  error = data.message
}

{#if fieldErrors.email}<p>{fieldErrors.email}</p>{/if}
```

## What's next

* [Field validations](/docs/features/field-validations): the rules you can set per field.
* [Spam protection](/docs/features/spam-protection): the challenges, the filter and the honeypot.
* [Email notifications](/docs/features/email-notifications): the auto response the visitor gets, keyed on `email`.

Stuck? Write to [help@formcarry.com](mailto:help@formcarry.com). Include the form id.


## Related topics

- [Introduction](/docs/introduction.md)
