Skip to content

Guide

Add a contact form to SvelteKit — even on adapter-static

Form actions are lovely — until adapter-static takes the server away. This form never needed one.

Last updated

Or skip the reading entirely.

Everything below is agent-work. Paste this to yours, then come back to a working form — you show up at the end to say "keep it."

Paste to your agent
Install the Postbag skill with `npx skills add faahim/postbag --skill postbag`, then use it to add a working contact form to this SvelteKit app and prove a test submission was stored.

Create the Form before you have an account

One command, no signup: a real Form id and submit URL that carry into production, inside a 24-hour sandbox — five test messages stored, nothing sent. The token prints once. Save it.

Create a Form before you have an account
sandbox_json="$(npx postbag --json sandbox create \
  --name "Contact" \
  --origin "https://example.com")"

submit_url="$(printf '%s\n' "$sandbox_json" | jq -er '.sandbox.submit_url')"
form_id="$(printf '%s\n' "$sandbox_json" | jq -er '.sandbox.id')"
sandbox_token="$(printf '%s\n' "$sandbox_json" | jq -er '.sandbox_token')"
claim_url="$(printf '%s\n' "$sandbox_json" | jq -er '.claim_url')"

printf 'Submit URL: %s\nForm ID: %s\nClaim URL: %s\nSandbox token: %s\nReplace YOUR_POSTBAG_SUBMIT_URL in the next snippet with the Submit URL above.\n' \
  "$submit_url" "$form_id" "$claim_url" "$sandbox_token"

Write the component

SvelteKit's own answer to forms is an action in +page.server.js — which is exactly the file adapter-static can't have. This component skips the question: plain reactive lets, one fetch, and the on:submit modifier syntax that compiles on Svelte 4 and 5 alike.

src/lib/ContactForm.svelte
<script>
  let email = "";
  let message = "";
  let status = "idle";

  async function handleSubmit() {
    status = "sending";
    try {
      const res = await fetch("YOUR_POSTBAG_SUBMIT_URL", {
        method: "POST",
        headers: { "content-type": "application/json" },
        body: JSON.stringify({ email, message }),
      });
      status = res.ok ? "sent" : "error";
    } catch {
      status = "error";
    }
  }
</script>

{#if status === "sent"}
  <p role="status">Thanks! Your message has been sent.</p>
{:else}
  <form on:submit|preventDefault={handleSubmit}>
    <label>
      Email
      <input type="email" bind:value={email} required />
    </label>
    <label>
      Message
      <textarea bind:value={message} required></textarea>
    </label>
    <button type="submit" disabled={status === "sending"}>
      {status === "sending" ? "Sending…" : "Send"}
    </button>
    {#if status === "error"}
      <p role="status">Something went wrong. Please try again.</p>
    {/if}
  </form>
{/if}
Any +page.svelte
<script>
  import ContactForm from "$lib/ContactForm.svelte";
</script>

<ContactForm />

Send one and watch it land

Run the dev server and send yourself something, or test from the terminal. Then read the sandbox back — the message is stored, timestamped, and hasn't gone anywhere. Which, before you've claimed anything, is exactly right.

Send one test from your terminal
curl --fail --silent --show-error -X POST "$submit_url" \
  -H "content-type: application/json" \
  -d '{ "email": "[email protected]", "message": "hello from the terminal" }'
See it stored
POSTBAG_SANDBOX_TOKEN="$sandbox_token" npx postbag sandbox status

Claim it when you're ready

The creation response included a claim link. Open it, sign in — Google, GitHub, or an emailed code — and the sandbox becomes a real Form in your own workspace. Same id, same submit URL: the page you just wired needs no edit. Your test messages come along, still marked as tests.

Turn on email

Claim it, point it at your inbox, route the Form. New messages are saved the moment they arrive and sent right after — retry included, at no extra thought.

Connect your inbox, then route the Form to it
destination_json="$(curl --fail --silent --show-error -X POST https://postbag.dev/v1/destinations \
  -H "Authorization: Bearer pb_live_…" \
  -H "content-type: application/json" \
  -d '{ "type": "email", "config": { "to": ["[email protected]"] } }')"
destination_id="$(printf '%s\n' "$destination_json" | jq -er '.id')"
route_body="$(jq -n --arg form_id "$form_id" --arg destination_id "$destination_id" \
  '{ form_id: $form_id, destination_id: $destination_id }')"

curl --fail --silent --show-error -X POST https://postbag.dev/v1/routes \
  -H "Authorization: Bearer pb_live_…" \
  -H "content-type: application/json" \
  -d "$route_body"

The parts that bite

Prerendering is fine
The component renders to static markup and the fetch happens on click. prerender = true, adapter-static, a CDN — none of them change anything here.
The origin is part of the deal
A sandbox Form only accepts browser posts from the origin you gave at creation. Building locally? Create it with your dev address (say http://localhost:4321) and add your real domain after you claim. Terminal tests carry no origin, so curl always gets through.
JSON posts skip the honeypot
The hidden _gotcha input is for plain HTML forms only. A JSON fetch that fills it looks like a bot — leave it out.
Five tests, then it wants a decision
A sandbox holds five test messages of up to 16 KiB each, for 24 hours, and sends nothing anywhere. That is the rehearsal budget. Claiming makes it permanent; letting it expire costs nothing.

Questions, answered

Should I use a form action when I do have a server?
If you like progressive enhancement with use:enhance, go ahead — post the fields to Postbag from your action. The static-friendly version above just works everywhere, server or not.
Does this compile on Svelte 5?
Yes. Files without runes run in legacy mode, where let is reactive and on:submit modifiers work. Migrating to runes later is a local change.
Where should the file live?
src/lib is the convention, giving you the $lib import alias. Anywhere importable works.
What happens if the visitor's network drops?
The fetch rejects, the form shows its error line, and their text stays in the fields for a retry. Nothing half-arrives.

Give the form job to your agent.

It can build, wire and test the form before you even sign up. Claim it when it's worth keeping.