Skip to main content

Frameworks

SvelteKit

Wire a contact form to Postboi with a one-line SvelteKit action.


postboi/kit reads FormData, sends it, and returns { success: true }, or fail(400, { error }) on failure. A contact-form action is a single line.

// +page.server.ts
import { mail } from 'postboi/kit'

export const actions = { default: mail }
// +page.server.ts
import { mail } from 'postboi/kit'

export const actions = { default: mail }

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.

<!-- +page.svelte -->
<script lang="ts">
	import { enhance } from '$app/forms'

	let email = $state('')
</script>

<form method="POST" use:enhance enctype="multipart/form-data">
	<input type="hidden" name="_subject" value="Contact Form" />
	<input type="hidden" name="_reply_to" value={email} />
	<input
		type="text"
		name="_honey"
		tabindex="-1"
		autocomplete="off"
		aria-hidden="true"
		style="position: absolute; left: -9999px; height: 0; width: 0; opacity: 0"
	/>
	<input name="contact→name" placeholder="Name" required />
	<input name="contact→email" type="email" placeholder="Email" required bind:value={email} />
	<textarea name="details→message" placeholder="Message"></textarea>
	<input type="file" name="details→attachments" multiple />
	<button type="submit">Send</button>
</form>
<!-- +page.svelte -->
<script lang="ts">
	import { enhance } from '$app/forms'

	let email = $state('')
</script>

<form method="POST" use:enhance enctype="multipart/form-data">
	<input type="hidden" name="_subject" value="Contact Form" />
	<input type="hidden" name="_reply_to" value={email} />
	<input
		type="text"
		name="_honey"
		tabindex="-1"
		autocomplete="off"
		aria-hidden="true"
		style="position: absolute; left: -9999px; height: 0; width: 0; opacity: 0"
	/>
	<input name="contact→name" placeholder="Name" required />
	<input name="contact→email" type="email" placeholder="Email" required bind:value={email} />
	<textarea name="details→message" placeholder="Message"></textarea>
	<input type="file" name="details→attachments" multiple />
	<button type="submit">Send</button>
</form>

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:

<script lang="ts">
	import { enhance } from '$app/forms'
	import Captcha from 'postboi/svelte'
</script>

<form method="POST" use:enhance enctype="multipart/form-data">
	<!-- your fields… -->
	<Captcha />
	<button type="submit">Send</button>
</form>
<script lang="ts">
	import { enhance } from '$app/forms'
	import Captcha from 'postboi/svelte'
</script>

<form method="POST" use:enhance enctype="multipart/form-data">
	<!-- your fields… -->
	<Captcha />
	<button type="submit">Send</button>
</form>

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:

<!-- +page.svelte -->
<script lang="ts">
	import { mail } from 'postboi/remote'
	import Captcha from 'postboi/svelte'

	let email = $state('')
</script>

<form {...mail} enctype="multipart/form-data">
	<input {...mail.fields._subject.as('hidden', 'Contact Form')} />
	<input {...mail.fields._reply_to.as('hidden', email)} />
	<Captcha />

	<input {...mail.fields.contact.name.as('text')} placeholder="Name" required />
	<input {...mail.fields.contact.email.as('email')} placeholder="Email" required bind:value={email} />
	<textarea {...mail.fields.details.message.as('text')} placeholder="Message"></textarea>
	<input {...mail.fields.details.attachments.as('file')} multiple />

	<button disabled={!!mail.pending}>{mail.pending ? 'Sending…' : 'Send'}</button>
</form>

{#if mail.result?.success}
	<p>Thanks — we'll be in touch!</p>
{:else if mail.result}
	<p>{mail.result.error}</p>
{/if}
<!-- +page.svelte -->
<script lang="ts">
	import { mail } from 'postboi/remote'
	import Captcha from 'postboi/svelte'

	let email = $state('')
</script>

<form {...mail} enctype="multipart/form-data">
	<input {...mail.fields._subject.as('hidden', 'Contact Form')} />
	<input {...mail.fields._reply_to.as('hidden', email)} />
	<Captcha />

	<input {...mail.fields.contact.name.as('text')} placeholder="Name" required />
	<input {...mail.fields.contact.email.as('email')} placeholder="Email" required bind:value={email} />
	<textarea {...mail.fields.details.message.as('text')} placeholder="Message"></textarea>
	<input {...mail.fields.details.attachments.as('file')} multiple />

	<button disabled={!!mail.pending}>{mail.pending ? 'Sending…' : 'Send'}</button>
</form>

{#if mail.result?.success}
	<p>Thanks — we'll be in touch!</p>
{:else if mail.result}
	<p>{mail.result.error}</p>
{/if}

Two lines of setup, since the feature is experimental:

// svelte.config.js — or inline in the sveltekit() vite plugin
kit: { experimental: { remoteFunctions: true } }
// svelte.config.js — or inline in the sveltekit() vite plugin
kit: { experimental: { remoteFunctions: true } }
// vite.config.ts — remote modules must reach the SvelteKit transform,
// not Vite's dependency prebundle. `bunx postboi init` adds this for you.
optimizeDeps: { exclude: ['postboi/remote'] }
// vite.config.ts — remote modules must reach the SvelteKit transform,
// not Vite's dependency prebundle. `bunx postboi init` adds this for you.
optimizeDeps: { exclude: ['postboi/remote'] }

The postboi/vite plugin 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.name replaces contact→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:enhance import — and resets it after a successful submit. mail.pending counts in-flight submissions, mail.result is 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():

// contact.schema.ts
export const schema = v.object({
	name: v.pipe(v.string(), v.minLength(1)),
	email: v.pipe(v.string(), v.email()),
	_honey: v.optional(v.string()), // HONEYPOT_FIELD
	_captcha: v.optional(v.string()) // TURNSTILE_REMOTE_FIELD
})
// contact.schema.ts
export const schema = v.object({
	name: v.pipe(v.string(), v.minLength(1)),
	email: v.pipe(v.string(), v.email()),
	_honey: v.optional(v.string()), // HONEYPOT_FIELD
	_captcha: v.optional(v.string()) // TURNSTILE_REMOTE_FIELD
})
// contact.remote.ts
export const send = form(schema, async (data) => {
	const body: Record<string, string> = { name: data.name, email: data.email }
	if (data._honey) body._honey = data._honey
	if (data._captcha) body._captcha = data._captcha
	await mail({ body })
})
// contact.remote.ts
export const send = form(schema, async (data) => {
	const body: Record<string, string> = { name: data.name, email: data.email }
	if (data._honey) body._honey = data._honey
	if (data._captcha) body._captcha = data._captcha
	await mail({ body })
})

_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 with captcha_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 with captcha: { 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():

// src/lib/mail.remote.ts
import { remote } from 'postboi/kit'
import Resend from 'postboi/resend'
import { RESEND_API_KEY } from '$env/static/private'

const resend = new Resend({ api_key: RESEND_API_KEY, default: { from: 'no-reply@example.com' } })

export const contact = remote(resend, { to: 'team@example.com' })
// src/lib/mail.remote.ts
import { remote } from 'postboi/kit'
import Resend from 'postboi/resend'
import { RESEND_API_KEY } from '$env/static/private'

const resend = new Resend({ api_key: RESEND_API_KEY, default: { from: 'no-reply@example.com' } })

export const contact = remote(resend, { to: 'team@example.com' })

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.

import Resend from 'postboi/resend'
import { action } from 'postboi/kit'
import { RESEND_API_KEY, EMAIL_FROM_ADDRESS } from '$env/static/private'

const mail = new Resend({ api_key: RESEND_API_KEY, default: { from: EMAIL_FROM_ADDRESS } })

export const actions = {
	default: action(mail, { status: 422, to: 'team@example.com' })
}
import Resend from 'postboi/resend'
import { action } from 'postboi/kit'
import { RESEND_API_KEY, EMAIL_FROM_ADDRESS } from '$env/static/private'

const mail = new Resend({ api_key: RESEND_API_KEY, default: { from: EMAIL_FROM_ADDRESS } })

export const actions = {
	default: action(mail, { status: 422, to: 'team@example.com' })
}