← All guides
SvelteKit

SvelteKit contact form without a backend

A working SvelteKit contact form on a static site: one HTML form or fetch() call, no form action, no +page.server.js. Email alerts, honeypot spam filter.

SvelteKit's built-in way to handle a form is a form action in +page.server.js. That needs a server. The SvelteKit docs say it plainly: "Pages with actions cannot be prerendered, because a server must be able to handle the action POST requests." If your site uses adapter-static, or you simply don't want to run server code for one contact form, the action has nowhere to run.

With Formlane, the form posts straight from the browser to https://smallwerk.com/formlane/api/submit. Formlane stores the message and emails it to your account address, with reply-to set to the sender. No +page.server.js, no email provider, no secret. It works the same with every SvelteKit adapter, because the form itself is plain HTML. Submissions are stored in Frankfurt.

Quick start

  1. Sign in to Formlane, create a form and copy its access key (fl_…).
  2. Paste the form below into src/routes/contact/+page.svelte and replace fl_YOUR_ACCESS_KEY with your key. The key is public by design: it only allows sending to this one form, so it is fine in client code.
  3. Set the hidden redirect field to a thank-you page on your own domain, for example https://example.com/thanks.
  4. In the form settings, restrict the form to your production domain, so forms on other websites can't post to it.
  5. Build and deploy, send a test message, then check your inbox and the Formlane dashboard.

Option 1: a plain HTML form (works without JavaScript)

src/routes/contact/+page.svelte
<form action="https://smallwerk.com/formlane/api/submit" method="POST">
  <input type="hidden" name="access_key" value="fl_YOUR_ACCESS_KEY" />
  <input type="hidden" name="subject" value="New message from my SvelteKit site" />
  <input type="hidden" name="redirect" value="https://example.com/thanks" />

  <!-- Honeypot: hidden from people, leave it unchecked -->
  <input type="checkbox" name="botcheck" style="display:none" tabindex="-1" autocomplete="off" />

  <label for="name">Name</label>
  <input id="name" type="text" name="name" required />

  <label for="email">Email</label>
  <input id="email" type="email" name="email" required />

  <label for="message">Message</label>
  <textarea id="message" name="message" rows="5" required></textarea>

  <button type="submit">Send</button>
</form>

After a successful submission the browser is redirected to your redirect URL. Use an absolute URL (with https://). Without a redirect field, Formlane uses the default redirect from your form settings, or its own hosted thank-you page.

The email field becomes the reply-to address of the notification, so you can answer with one click. Add as many other fields as you like (company, phone, a <select>): every named field ends up in the email and the dashboard.

A thank-you page

Create src/routes/thanks/+page.svelte with a short message. If your site is prerendered, this page is just one more static HTML file.

Option 2: stay on the page with fetch()

Want an inline success message instead of a page change? Keep the same form and send it with fetch(). With the header Accept: application/json, Formlane answers with JSON instead of a redirect. new FormData(form) sends all fields, and an unchecked honeypot checkbox is simply not included, so real visitors never send it.

This version uses Svelte 5 runes. Because the form keeps its action and method, it still works if JavaScript fails to load.

src/routes/contact/+page.svelte
<script>
  const ENDPOINT = "https://smallwerk.com/formlane/api/submit";

  // Formlane's `message` is always English, so show your own text per error code.
  const MESSAGES = {
    rate_limited: "Too many messages in a short time. Please try again in a few minutes.",
    limit_reached: "This form can't take more messages this month. Please email us directly.",
    origin_not_allowed: "This form isn't allowed on this website.",
    invalid_key: "This form isn't set up correctly.",
  };

  let status = $state("idle"); // "idle" | "sending" | "sent" | "error"
  let errorText = $state("");

  async function handleSubmit(event) {
    event.preventDefault();
    const form = event.currentTarget;
    status = "sending";

    try {
      const res = await fetch(ENDPOINT, {
        method: "POST",
        headers: { Accept: "application/json" },
        body: new FormData(form),
      });
      const json = await res.json();

      if (json.success) {
        status = "sent";
        form.reset();
      } else {
        status = "error";
        errorText = MESSAGES[json.error] ?? "Something went wrong. Please try again.";
      }
    } catch {
      status = "error";
      errorText = "Network error. Please try again.";
    }
  }
</script>

{#if status === "sent"}
  <p>Thanks! Your message has been sent.</p>
{:else}
  <form action={ENDPOINT} method="POST" onsubmit={handleSubmit}>
    <input type="hidden" name="access_key" value="fl_YOUR_ACCESS_KEY" />
    <input type="hidden" name="subject" value="New message from my SvelteKit site" />

    <!-- Honeypot: hidden from people, leave it unchecked -->
    <input type="checkbox" name="botcheck" style="display:none" tabindex="-1" autocomplete="off" />

    <label for="name">Name</label>
    <input id="name" type="text" name="name" required />

    <label for="email">Email</label>
    <input id="email" type="email" name="email" required />

    <label for="message">Message</label>
    <textarea id="message" name="message" rows="5" required></textarea>

    <button type="submit" disabled={status === "sending"}>
      {status === "sending" ? "Sending…" : "Send"}
    </button>

    {#if status === "error"}
      <p role="alert">{errorText}</p>
    {/if}
  </form>
{/if}

What the response looks like

  • Success (HTTP 200): {"success": true, "message": "Form submitted successfully", "id": "…"}
  • Error (4xx/5xx): {"success": false, "message": "…", "error": "…"}. Error codes include invalid_key, origin_not_allowed, rate_limited and limit_reached.

Formlane sends CORS headers, so this cross-origin fetch() works from any domain you allow in the form settings.

Why not use:enhance?

use:enhance from $app/forms is built for SvelteKit's own form actions. The SvelteKit docs describe it for forms that post to an action in +page.server.js, not to an external URL. For a form that posts to Formlane, use a normal onsubmit handler with fetch() as in Option 2. You get the same "no page reload" result without a server.

Keep the key in an environment variable (optional)

The access key is public, so hard-coding it is fine. If you prefer one place to change it, put it in .env:

.env
PUBLIC_FORMLANE_KEY=fl_YOUR_ACCESS_KEY

and read it in the component:

src/routes/contact/+page.svelte
<script>
  import { PUBLIC_FORMLANE_KEY } from "$env/static/public";
</script>

<input type="hidden" name="access_key" value={PUBLIC_FORMLANE_KEY} />

What actually protects the form is the list of allowed domains in the form settings, not hiding the key.

Spam without a captcha

  • Honeypot: the hidden botcheck checkbox. People never see it; bots that tick it get a normal success response and nothing is stored.
  • Rate limit: at most 10 submissions per 10 minutes per visitor and form.
  • Content rules: simple rules such as link flooding. Flagged messages go to a separate Spam tab in your dashboard and are not emailed.

No captcha and no third-party scripts on your page. More in the guide Contact form spam without a captcha.

Works with every adapter

The form is plain HTML plus one optional fetch() call, so nothing depends on where SvelteKit runs:

  • adapter-static on GitHub Pages, Cloudflare Pages or any static host
  • adapter-vercel, adapter-netlify, adapter-cloudflare
  • adapter-node on your own server

Host-specific notes: Vercel, Cloudflare Pages, GitHub Pages.

Limits

64 KB per submission, 50 fields, 10,000 characters per field, 10 submissions per 10 minutes per visitor and form. File uploads are not supported yet.

Other ways to do it (honest comparison)

OptionGood forWatch out for
Form action in +page.server.js + an email APIFull control, custom logic, data in your own stackNeeds a server adapter; the page can't be prerendered; you build validation, spam protection, rate limiting, storage and email delivery yourself
Hosted form backend (Formlane or others)Contact and lead forms on any SvelteKit site, including fully static onesMonthly submission quotas; features differ between services (file uploads, webhooks, autoresponders)

Formlane fits if you want a plain HTML form, submissions stored in Frankfurt and a spam filter without captcha. If you need file uploads, autoresponders, webhooks or a captcha today, pick a service that already has them: Formlane doesn't yet.

Using a coding agent?

Building with Claude Code, Cursor or another agent? The Formlane prompt for AI coding agents gives it the endpoint, both snippets and the honeypot in one copy-paste block. To read your submissions from code or from an agent, use the read-only REST API and MCP server, available on every plan, including Free.

Prefer to click a form together? The contact form generator writes the HTML for you.

Pricing

Free: 250 submissions a month and up to 5 forms. Starter: $8/month for 2,500 submissions. Pro: $19/month for 25,000 submissions. Prices are in USD, plus applicable taxes. Details on the Formlane page.

FAQ

Does this work with adapter-static and prerendered pages?

Yes. The form posts from the visitor's browser to Formlane, so your SvelteKit build doesn't need a server. Form actions can't be used on prerendered pages; this approach doesn't use one.

Do I need +page.server.js or a form action?

No. The form's action points at https://smallwerk.com/formlane/api/submit. Formlane receives the POST, stores the message and emails it to you.

Can I use use:enhance?

Not with an external endpoint. use:enhance is for SvelteKit form actions. Use an onsubmit handler with fetch() and Accept: application/json instead, as shown in Option 2.

Is the access key safe in client code?

Yes. The Formlane access key is public by design and only allows sending submissions to that one form. Restrict the form to your domain in its settings, so forms on other websites can't use it.

How do I stop spam without reCAPTCHA?

Keep the hidden botcheck checkbox in the form. Together with the rate limit and the content rules, that handles typical form spam without a captcha. Flagged messages land in a Spam tab and are not emailed.

Can visitors upload files?

Not yet. Formlane accepts text fields only for now.

What happens when I reach the monthly limit?

New submissions are rejected with the error limit_reached until the next calendar month or an upgrade. Submissions you already have are kept.

Where are submissions stored?

Submissions are stored in Frankfurt. The privacy policy lists all providers.

Get your access key

Free for 250 submissions a month. Submissions stored in Frankfurt.

Get your access key