How to Use a SvelteKit Form Action to Cut Checkout Drop-Off
A SvelteKit form action handles your checkout server-side, so the form works even before JavaScript loads, and use:enhance upgrades it to a no-reload submit. Return the entered values with fail() so a validation error never clears the form. That removes the friction that makes people abandon a checkout.
A SvelteKit checkout drops users when a validation error wipes the form or the page stalls waiting on JavaScript. A form action fixes both: it validates server-side so the form works without client JS, use:enhance upgrades it to a no-reload submit, and returning the values with fail() means an error never clears the fields.
Why a form action is the right tool
The genuinely SvelteKit-specific construct here is the form action — a server function in +page.server.js that a <form> posts to directly. Because it's a real HTML form submission, it works even if the client bundle hasn't loaded. use:enhance then layers on the smooth, no-reload experience. That's progressive enhancement: a resilient baseline plus an upgrade, not a JS-only form that breaks when something fails to load.
The form action
Validate and, on failure, return the values so the page can repopulate. Note the SvelteKit 2 idiom — redirect() is called directly, not thrown:
// src/routes/checkout/+page.server.js
import { fail, redirect } from '@sveltejs/kit'
export const actions = {
default: async ({ request }) => {
const data = await request.formData()
const email = data.get('email')
const card = data.get('card')
if (!email) return fail(400, { email, error: 'Enter your email.' })
if (!card) return fail(400, { email, error: 'Enter your card details.' })
// charge / create subscription here…
redirect(303, '/welcome') // SvelteKit 2: no `throw`
},
}
fail(400, { email }) sends the email back to the page so it isn't lost, and the validation runs on the server where it can't be skipped.
The form with use:enhance
In Svelte 5, read the action result from the form prop via $props():
<!-- src/routes/checkout/+page.svelte -->
<script>
import { enhance } from '$app/forms'
let { form } = $props()
</script>
<form method="POST" use:enhance>
<input name="email" type="email" value={form?.email ?? ''} required />
<input name="card" inputmode="numeric" required />
{#if form?.error}<p class="error">{form.error}</p>{/if}
<button>Complete purchase</button>
</form>
value={form?.email} is the anti-abandonment line — a failed submit comes back with the email intact. And because the form posts to the action, it still submits server-side if use:enhance hasn't loaded; the directive just adds the no-reload error display.
Steps to apply it
- Move checkout validation into a
defaultaction in+page.server.js, returningfail(400, { ...values })on each failure branch. - Bind each input's
valuetoform?.fieldso a failed submit repopulates. - Add
use:enhanceto the<form>for the no-reload upgrade. - Use
redirect(303, '/welcome')on success — nothrow, per SvelteKit 2. - Keep the field set minimal; every extra field on a checkout is a reason to abandon.
Measure the drop-off you're fixing
Fire checkout_started and checkout_completed, then this HogQL gives the abandonment rate:
SELECT
countDistinctIf(person_id, event = 'checkout_started') AS started,
countDistinctIf(person_id, event = 'checkout_completed') AS completed,
round(
(countDistinctIf(person_id, event = 'checkout_started')
- countDistinctIf(person_id, event = 'checkout_completed'))
/ countDistinctIf(person_id, event = 'checkout_started') * 100,
1) AS drop_off_pct
FROM events
WHERE timestamp > now() - INTERVAL 30 DAY
Illustrative sample output:
| started | completed | drop_off_pct |
|---|---|---|
| 640 | 430 | 32.8 |
Ship the form action, then watch this rate. If you'd like the friction found and the fix opened as a Pull Request for you, that's what Velyr does.
Frequently asked questions
What is a form action in SvelteKit?
A form action is a server-side function exported from a +page.server.js file that a form posts to. The form submits as a native HTML POST, so it works without client JavaScript; adding use:enhance progressively upgrades it to a no-reload submit while keeping that resilient baseline.
How do I keep checkout fields after a validation error in SvelteKit?
Return the submitted values from the action with fail(400, { email, ...rest }). SvelteKit exposes them on the page's form prop, so you bind each input's value back to it and the user keeps everything they typed.
Do I still need throw before redirect in SvelteKit?
No. In SvelteKit 2, redirect() and error() throw internally, so you call redirect(303, '/path') directly without throw. Writing throw redirect(...) is the old SvelteKit 1 idiom.
Velyr is an AI growth agent that ships one weekly conversion fix as a GitHub Pull Request — you approve it over Telegram, and it rolls itself back if the numbers drop.
Start the Growth Agent