Progressive forms matter
Form handling in web applications often splits into two camps: server-rendered forms that require a full page reload, or JavaScript-heavy client-side solutions that bundle validation and submission logic into the browser. SvelteKit form actions for Svelte & SvelteKit developers offer a third path—one that works without client JavaScript but gains progressive enhancement through optional browser APIs. This approach lets your team ship forms that function immediately on any connection speed, then layer smooth interactions on top for users with JavaScript enabled.
Over the past 20 days, we've built components, routes, and layouts. Today we're tackling the layer that ties them together: receiving user input, validating it server-side, and communicating results back to the UI. Form actions are where SvelteKit shines as a full-stack framework.
Define actions in +page.server.ts
Every SvelteKit route can export an actions object from its +page.server.ts file. These functions run on the server, receive form data, and return success or failure responses. Think of them as your route's POST handler, minus the boilerplate.
Start with a concrete example: a newsletter subscription form. In a typical e-commerce checkout or SaaS onboarding flow, we need to collect an email, validate it, and store it. Here's the server-side implementation:
// src/routes/subscribe/+page.server.ts
import { fail } from '@sveltejs/kit'
import { subscribeUser } from '$lib/db'
export const actions = {
default: async ({ request }) => {
const data = await request.formData()
const email = String(data.get('email'))
// Validate on the server
if (!email.includes('@')) {
return fail(400, { email, invalid: true })
}
try {
await subscribeUser(email)
return { success: true }
} catch (error) {
return fail(500, { email, error: 'Database error' })
}
}
}
The function receives a request object, extracts form data, and returns either a success object or calls fail() with an HTTP status code and payload. SvelteKit automatically handles serialization and passes this response to the page component. Notice we validate on the server—client-side validation can be bypassed, so server validation is non-negotiable for security.
You can also name multiple actions beyond default. If your form has separate "Save draft" and "Publish" buttons, define them as:
export const actions = {
subscribe: async ({ request }) => { /* ... */ },
unsubscribe: async ({ request }) => { /* ... */ }
}
Then in your form, set formaction="/subscribe?/unsubscribe" on the button to route to the correct handler. This keeps related logic co-located with the route.
Use a form without JavaScript
In the component, we define a standard HTML form that works immediately:
// src/routes/subscribe/+page.svelte
<script>
export let form
</script>
<form method="POST">
<input
type="email"
name="email"
placeholder="you@example.com"
value={form?.email || ''}
/>
{#if form?.invalid}
<p class="error">Please enter a valid email.</p>
{/if}
{#if form?.success}
<p class="success">Subscribed! Check your inbox.</p>
{/if}
<button type="submit">Subscribe</button>
</form>
Here's the magic: this form works in a browser with JavaScript disabled. A user fills in their email, clicks submit, and the browser POSTs to the server. The action runs, validates, stores data, and returns a response. SvelteKit re-renders the page with the form data available as the form prop, showing success or error messages. No JavaScript required.
In production, we've seen this pattern in ride-hailing platforms where users on slow connections or restrictive networks need to complete profile setup. Progressive enhancement means even if the JavaScript bundle fails to download, the form still works.
The form prop automatically resets after successful submission (unless you return data that fails), and validation errors persist the user's input so they don't lose what they typed. This is handled by SvelteKit transparently.
Enhance with use:enhance
For users with JavaScript enabled, we can add smooth client-side submission using the use:enhance directive. This intercepts the form submission, sends data via fetch, and updates the page without a reload:
// src/routes/subscribe/+page.svelte
<script>
import { enhance } from '$app/forms'
export let form
let loading = false
</script>
<form
method="POST"
use:enhance={() => {
loading = true
return async ({ result }) => {
loading = false
if (result.type === 'success') {
// Form auto-resets, update UI as needed
} else if (result.type === 'failure') {
// Result contains form data and errors
}
}
}}
>
<input
type="email"
name="email"
placeholder="you@example.com"
value={form?.email || ''}
disabled={loading}
/>
{#if form?.invalid}
<p class="error">Please enter a valid email.</p>
{/if}
{#if form?.success}
<p class="success">Subscribed! Check your inbox.</p>
{/if}
<button type="submit" disabled={loading}>
{loading ? 'Subscribing...' : 'Subscribe'}
</button>
</form>
The use:enhance directive runs your callback when the form submits. You can disable inputs during loading, show spinners, or update other page state. The result object tells you whether the action succeeded or failed and includes any data it returned.
This is where we see real payoff in trading platforms or high-frequency e-commerce sites. Without use:enhance, every form submission triggers a navigation, losing scroll position and disrupting the user flow. With it, submission feels instant and local, yet the server validation and business logic remain bulletproof.
You can also return custom handlers to control the default behavior:
use:enhance={() => {
return async ({ result }) => {
if (result.type === 'success') {
// Don't auto-reset; we'll do it manually
// Redirect, show a toast, etc.
window.location.href = '/success'
}
}
}}
Validation and error handling
Server-side validation is the foundation. Use the fail() function to return validation errors with the original form data so users can correct their input:
export const actions = {
default: async ({ request }) => {
const data = await request.formData()
const email = String(data.get('email'))
const password = String(data.get('password'))
const errors = {}
if (!email.includes('@')) {
errors.email = 'Invalid email format'
}
if (password.length < 8) {
errors.password = 'Password must be at least 8 characters'
}
if (Object.keys(errors).length) {
return fail(400, { email, password, errors })
}
try {
await createUser(email, password)
return { success: true }
} catch (err) {
if (err.code === 'DUPLICATE_EMAIL') {
return fail(400, {
email,
password,
errors: { email: 'Email already registered' }
})
}
throw err
}
}
}
On the client, render error messages and re-populate fields:
<script>
export let form
</script>
<form method="POST">
<input
type="email"
name="email"
value={form?.email || ''}
/>
{#if form?.errors?.email}
<p class="error">{form.errors.email}</p>
{/if}
<input
type="password"
name="password"
value={form?.password || ''}
/>
{#if form?.errors?.password}
<p class="error">{form.errors.password}</p>
{/if}
<button type="submit">Sign Up</button>
</form>
We've found this pattern invaluable in CRM systems and SaaS platforms where users need immediate, clear feedback on what went wrong. Returning the original data lets users see exactly what they submitted, which builds confidence that the system understood them correctly.
Common mistakes
- Trusting client-side validation alone. If you only validate in the form component, attackers can bypass it by sending raw POST requests. Always validate on the server in your action.
- Not returning the original form data on failure. If you return
fail(400, { errors })without the original values, users can't correct their input without retyping everything. Return{ email, password, errors }alongside errors. - Forgetting to use form data in inputs. Binding to
form?.emailin the input'svalueattribute ensures users see what they submitted even if validation failed. Without it, the field appears empty. - Overcomplicating use:enhance callbacks. The default behavior already handles form reset and result processing. Only override it if you need custom navigation, redirects, or side effects.
- Mixing form actions with fetch calls. If you have both a
<form>with an action and JavaScript making fetch requests to the same endpoint, you're duplicating logic. Pick one approach per form.
Key takeaways
- Define actions in
+page.server.tsto handle form submissions server-side. They run securely and return success or failure responses. - Forms work without JavaScript. Users can submit, validate, and see results even on slow networks or with JS disabled.
- Use
use:enhanceto layer smooth client-side submission on top. It prevents full-page reloads and lets you control loading states and side effects. - Always validate on the server. Client validation is for UX; server validation is for security and correctness.
- Return original form data on validation errors. This preserves user input and lets them see what they submitted.
- Name multiple actions if your route handles different form types. Route buttons to the right handler via
formaction.






Comments
Loading comments…