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, and 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. A remote form only accepts fields scoped to it, so
<Captcha />gives the honeypot and the captcha token the form’s scoped names once the page’s JavaScript runs, and the managed captcha detects remote forms automatically. That makes a form with<Captcha />need JavaScript, as the captcha does anyway. A tripped honeypot still reports{ success: true }to the bot. - A failed send doesn’t throw.
mail.resultbecomes{ success: false, error }, with the reason inerror, so handle it in the markup (the{:else if mail.result}branch above) rather than in atryaround the submit. - Checkboxes read like answers. A ticked box (
true) is “Yes” in the email, and an unticked one (false) is left out rather than listed as “false”.
A remote form with a schema
remote() uses SvelteKit’s unchecked mode, so it accepts whatever the form submits and
the spam fields arrive intact. For typed fields and issues(), pass a Standard Schema (valibot, zod, arktype) first. Write it for
your own fields only: remote() keeps _honey and _captcha out of your schema and puts
them back after, so SvelteKit’s validation can’t drop them.
value is the post as your schema returned it. The honeypot, the captcha token, the
visitor’s IP and a failed send are handled as they are for any remote(). A tripped
honeypot returns { success: true } without running after, and an after that throws is
your error rather than a failed send, so it isn’t caught.
With a Postboi captcha key baked in, a form that arrives with no token at all is said once in the server’s log. That’s the sign a form library or a schema of your own dropped it.
_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():
Testing actions
postboi/kit imports under bun test or plain Node with no $app/server mock. Outside
Vite it resolves to a build that leaves $app/server alone, so action(), mail and webhook() work as they do in the app. remote() is the one export that needs SvelteKit,
and says so if you call it there.
Options from the post
When the options depend on the submission, the request or a CMS, pass a function instead of
an object. It gets the request event and the post as data, parsed once, and returns the
send options:
field(name)reads one text field, trimmed, and givesundefinedwhen it’s missing or blank. Reach for it overString(data.get(name)), which turns a missing field into the string"null": a reply-to of “null”, or a CMS lookup forstories/null.field.all(name)does the same for a field posted more than once (a checkbox group, a list of uploaded file URLs) and gives every non-blank value, or[]. Both trim, so a check that counts characters exactly, like amaxlengthwhere a line break is one character, readsdataitself.- A reply-to that isn’t an address (a visitor typing
asdfin the email box) is dropped with a warning rather than failing the send, so a typo doesn’t cost you the submission. datais the post. Change it if you need to: validate it, delete fields, add some.- Return
fail(…)from@sveltejs/kitto stop the send.action()hands it back as is. - A
_field you read that isn’t one of postboi’s own (_subject,_reply_to,_formand the rest) never reaches the email, so an id can travel in a hidden input instead of the action’s URL. A_field you don’t read renders as before. - The honeypot runs first. A bot that fills it gets
{ success: true }and your function never runs, so nothing is looked up or fetched on its behalf. - The visitor’s IP,
is_spamandfailon a send error all work as they do with an object. - A blank string counts as unset, so an empty CMS field falls through to the post or
default.*.fromtakes any address here (the API still refuses one your account can’t send from, withfrom_not_allowed). - Return
afterwith the options for work that should only happen once the email has gone: filing the enquiry in a CRM, subscribing the visitor, working out where to send them next. The object it returns is merged into the action’s result, soafter: async () => ({ link })gives the page{ success: true, link }. It doesn’t run for a bot, whether the honeypot or the captcha stopped it, or for a send that failed. An error it throws isn’t caught, because the email has already gone. Read any_field it needs in the resolver itself: one the resolver read is out of the post by the timeafterruns.
remote() takes the same function. There a returned fail(…) becomes { success: false, error }, using error from its data when there is one.
Recipe: form recipients from a CMS
Each form block keeps its own recipient, sender and subject in the CMS. The form posts which block it is, and the server looks it up, so a visitor can’t pick the recipient:
_story and _blok are read, so neither shows up in the email. form: true files an
untitled block as a form submission without letting a posted _form choose one.
Recipe: reply-to and subject from the fields
No hidden inputs mirroring the email field, and no subject built in the browser:
postboi doesn’t guess the reply-to from a field called email for you: plenty of forms
have an email field that isn’t the sender’s, and a guessed reply-to would go to the wrong
person without anyone noticing.
Recipe: attachments uploaded first
When the browser uploads files first and posts their URLs, fetch them into Files and drop
the field. Only fetch from the host you uploaded to, or the post decides what your server
requests:
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.