postboi/kit reads FormData, sends it, and returns { success: true }, or fail(400, { error }) on failure. A contact-form action is a single line.
The form
Point a multipart/form-data form at the action. Field names use the fieldset→field syntax to group related fields, and _subject (and friends) set the email’s special fields.
Wire up a hidden _reply_to field bound to the sender’s email. Then, when the
notification lands in your inbox, hitting Reply goes straight back to the person who
filled in the form, not to your no-reply from address.
For most contact forms, a reply-to address is all you need: keep from as your default
sending address and let reply-to carry the conversation. Only set a per-message from when you want the email itself to appear from a specific address.
The submitted FormData becomes a tidy HTML table in the email body. See FormData for how the table is built.
The hidden _honey field is the built-in honeypot: bots fill it, humans can’t see it, and a
filled honeypot skips the send while the action still returns { success: true }: the
bot learns nothing. Want more? Spam protection covers the invisible captcha too:
fully managed on the Postboi provider (one script tag, no keys), or bring your own Turnstile with
a single env var.
Or use the Captcha component
postboi/svelte ships that honeypot (plus the managed invisible captcha on
the Postboi provider) as a single prop-free component. Your form stays a native <form> (use:enhance and form libraries keep working); just drop it inside:
The publishable key is baked in by bunx postboi sync. See Spam protection.
A full, runnable version of this form lives in examples/sveltekit-provider-postboi.
Remote functions
SvelteKit’s remote functions (experimental)
let a library ship the whole backend. postboi/remote exports a ready-made remote form —
no +page.server.ts, no action, no endpoint. The component is the app:
Two lines of setup, since the feature is experimental:
The
postboi/viteplugin sets that exclude itself, so if you’re already using it (for edge deploys, say) you can drop the line.
Worth knowing:
- Field names nest instead of using arrows. Remote-form names must be valid JS paths,
so
fields.contact.namereplacescontact→name— the email’s grouped table comes out identical. Special fields (_subject,_reply_to, …) work unchanged. - No schema required. A contact form is arbitrary fields by design, so the handler
accepts whatever the form sends — spam checks and parsing happen in the send pipeline,
same as the classic action. Want client-side validation? Compose kit’s own preflight:
<form {...mail.preflight(schema)}>with any Standard Schema (Zod, Valibot). - Enhancement is built in. The spread progressively enhances the form — no
use:enhanceimport — and resets it after a successful submit.mail.pendingcounts in-flight submissions,mail.resultis the (ephemeral) return value. Custom submit behaviour uses the form’s own method:<form {...mail.enhance(async (form) => { await form.submit(); … })}>. Without JavaScript the form still posts and renders the result, exactly like a native action. - Spam protection carries over.
<Captcha />renders the honeypot under a remote-safe name, and the managed captcha detects remote forms automatically. A tripped honeypot still reports{ success: true }to the bot.
Writing your own remote form
remote() uses SvelteKit’s unchecked mode, so it accepts whatever the form submits and
the spam fields arrive intact. Writing your own form(schema, ...) instead — for typed
fields and issues() — changes that: SvelteKit validates against your schema first and
drops anything undeclared, silently. Leave the spam fields out and the honeypot is inert
and the captcha token never arrives, with no error to tell you.
Declare them, and forward them to mail():
_captcha rather than cf-turnstile-response: remote-form field names must be valid JS
paths, so the managed loader renames the token field. postboi strips both before rendering.
Sending through a bring-your-own provider while
<Captcha />is on the page fails withcaptcha_misconfigured— a token arrived with no secret to verify it against. Managed captcha needs the Postboi provider; on any other one, configure Turnstile yourself or opt out withcaptcha: { turnstile: false }. The mock provider is the exception: it’s credential-free by design, so it drops the token and sends anyway.
For a custom provider or forced fields, build your own in a .remote.ts file with remote() from postboi/kit — the remote counterpart of action():
Using a configured instance
Got a configured provider instance? Wrap it with action(). Any send option you pass
is merged into every send, and status sets the failure code.