Forms That Never Break

Most web forms are fragile. Disable JavaScript, and they break. Slow connection? Spinning forever. Error during submission? Lost data.

SvelteKit forms are different. They work without JavaScript—full page reload, data preserved. Add use:enhance, and they become instant—no reload, optimistic updates, loading states. Same form, two modes, zero configuration.

This is progressive enhancement: start with something that works everywhere, then make it better where possible.

The Foundation: HTML Forms

Before any Svelte magic, let’s remember how forms actually work.

<form method="POST" action="/settings">
	<label>
		Name
		<input name="name" value="Alice" />
	</label>
	<button type="submit">Save</button>
</form>

When submitted:

  1. Browser collects all named inputs
  2. Sends a POST request to /settings
  3. Server processes the request
  4. Server returns a response (usually a redirect or new page)
  5. Browser displays the response

No JavaScript involved. Works in every browser since the 90s. This is what SvelteKit builds on.

Form Actions

Form actions are functions that handle form submissions. They live in +page.server.ts:

// routes/settings/+page.server.ts
import { fail, redirect } from '@sveltejs/kit'
import { db } from '$lib/db'

export async function load({ locals }) {
	return {
		user: await db.getUser(locals.user.id)
	}
}

export const actions = {
	default: async ({ request, locals }) => {
		const formData = await request.formData()
		const name = formData.get('name')

		// Validation
		if (!name || typeof name !== 'string') {
			return fail(400, {
				error: 'Name is required',
				name // Return the value so the form can be repopulated
			})
		}

		if (name.length < 2) {
			return fail(400, {
				error: 'Name must be at least 2 characters',
				name
			})
		}

		// Update database
		await db.updateUser(locals.user.id, { name })

		// Return success (or redirect)
		return { success: true }
	}
}

The form to use this action:

<!-- routes/settings/+page.svelte -->
<script>
	let { data, form } = $props()
</script>

<h1>Settings</h1>

{#if form?.success}
	<p class="success">Settings saved!</p>
{/if}

{#if form?.error}
	<p class="error">{form.error}</p>
{/if}

<form method="POST">
	<label>
		Name
		<input name="name" value={form?.name ?? data.user.name} />
	</label>
	<button type="submit">Save</button>
</form>

This form works without JavaScript. Submit it, and:

  1. Browser posts to the current URL
  2. SvelteKit runs the default action
  3. Action validates and updates the database
  4. Page reloads with new data (or error messages)

The form prop contains whatever the action returned. Use it to show errors and repopulate fields.

Named Actions

When a page has multiple forms, use named actions:

// routes/settings/+page.server.ts
export const actions = {
	updateProfile: async ({ request, locals }) => {
		const formData = await request.formData()
		// ... handle profile update
	},

	changePassword: async ({ request, locals }) => {
		const formData = await request.formData()
		// ... handle password change
	},

	deleteAccount: async ({ request, locals }) => {
		// ... handle account deletion
	}
}

Reference them with the action attribute:

<form method="POST" action="?/updateProfile">
	<!-- profile fields -->
</form>

<form method="POST" action="?/changePassword">
	<!-- password fields -->
</form>

<form method="POST" action="?/deleteAccount">
	<button type="submit">Delete Account</button>
</form>

The ?/actionName syntax tells SvelteKit which action to run.

Progressive Enhancement with use:enhance

The form works without JavaScript. Now let’s make it better with JavaScript.

<script>
	import { enhance } from '$app/forms'

	let { data, form } = $props()
</script>

<form method="POST" use:enhance>
	<!-- same form content -->
</form>

That’s it. One import, one directive. Now the form:

  • Submits without a full page reload
  • Updates form prop automatically
  • Keeps scroll position
  • Handles redirects client-side

The user experience is dramatically better, but the form still works if JavaScript fails to load.

Customizing use:enhance

use:enhance accepts a callback for custom behavior:

<script>
	import { enhance } from '$app/forms'

	let { data, form } = $props()
	let submitting = $state(false)
</script>

<form
	method="POST"
	use:enhance={() => {
		submitting = true

		return async ({ update, result }) => {
			submitting = false

			if (result.type === 'success') {
				// Custom success handling
				showToast('Settings saved!')
			}

			// Apply the default behavior (update form prop, etc.)
			await update()
		}
	}}
>
	<label>
		Name
		<input name="name" value={form?.name ?? data.user.name} disabled={submitting} />
	</label>

	<button type="submit" disabled={submitting}>
		{submitting ? 'Saving...' : 'Save'}
	</button>
</form>

The callback runs before submission. Return a function to run after the response arrives.

Common patterns:

  • submitting state for loading indicators
  • Custom success messages or toasts
  • Resetting form fields after success
  • Conditional redirect handling

Validation Patterns

Server-side validation

Always validate on the server. Client-side validation is for UX, not security.

export const actions = {
	default: async ({ request }) => {
		const formData = await request.formData()

		const email = formData.get('email')
		const password = formData.get('password')

		const errors: Record<string, string> = {}

		if (!email || typeof email !== 'string') {
			errors.email = 'Email is required'
		} else if (!email.includes('@')) {
			errors.email = 'Invalid email format'
		}

		if (!password || typeof password !== 'string') {
			errors.password = 'Password is required'
		} else if (password.length < 8) {
			errors.password = 'Password must be at least 8 characters'
		}

		if (Object.keys(errors).length > 0) {
			return fail(400, { errors, email }) // Don't return password
		}

		// ... proceed with valid data
	}
}
<form method="POST" use:enhance>
	<label>
		Email
		<input
			name="email"
			type="email"
			value={form?.email ?? ''}
			aria-invalid={form?.errors?.email ? 'true' : undefined}
		/>
		{#if form?.errors?.email}
			<span class="error">{form.errors.email}</span>
		{/if}
	</label>

	<label>
		Password
		<input
			name="password"
			type="password"
			aria-invalid={form?.errors?.password ? 'true' : undefined}
		/>
		{#if form?.errors?.password}
			<span class="error">{form.errors.password}</span>
		{/if}
	</label>

	<button type="submit">Sign Up</button>
</form>

Client-side validation for UX

HTML5 validation gives immediate feedback:

<input name="email" type="email" required pattern="[^@]+@[^@]+\.[^@]+" />

For complex validation, use Svelte reactivity:

<script>
	let password = $state('')
	let confirmPassword = $state('')

	let passwordError = $derived(
		password.length > 0 && password.length < 8 ? 'Password must be at least 8 characters' : null
	)

	let confirmError = $derived(
		confirmPassword.length > 0 && confirmPassword !== password ? 'Passwords do not match' : null
	)
</script>

<input name="password" type="password" bind:value={password} />
{#if passwordError}
	<span class="error">{passwordError}</span>
{/if}

<input name="confirmPassword" type="password" bind:value={confirmPassword} />
{#if confirmError}
	<span class="error">{confirmError}</span>
{/if}

Client validation is instant. Server validation is authoritative. Use both.

Handling Different Response Types

Actions can return different response types:

import { fail, redirect } from '@sveltejs/kit'

export const actions = {
	default: async ({ request }) => {
		try {
			const data = await processForm(request)

			// Option 1: Return data (stays on page)
			return { success: true, data }

			// Option 2: Redirect (goes to another page)
			redirect(303, '/dashboard')
		} catch (e) {
			// Option 3: Return failure (stays on page with errors)
			return fail(400, { error: e.message })
		}
	}
}

With use:enhance, redirects happen client-side. Without it, they’re full page navigations. Either way, the user ends up in the right place.

File Uploads

Forms can handle file uploads:

<form method="POST" enctype="multipart/form-data" use:enhance>
	<input type="file" name="avatar" accept="image/*" />
	<button type="submit">Upload</button>
</form>
export const actions = {
	default: async ({ request }) => {
		const formData = await request.formData()
		const file = formData.get('avatar')

		if (file instanceof File && file.size > 0) {
			const buffer = await file.arrayBuffer()
			// ... save file
		}

		return { success: true }
	}
}

The enctype="multipart/form-data" attribute is required for file uploads. SvelteKit handles the rest.

The Progressive Enhancement Mindset

Think of forms in layers:

  1. Base layer (HTML): Form submits, server processes, page reloads. Works everywhere.

  2. Enhanced layer (use:enhance): No reload, loading states, smooth transitions. Works when JS loads.

  3. Polish layer (validation, UX): Instant feedback, disabled states, animations. Nice to have.

Build the base layer first. Test it without JavaScript. Then add enhancements. This order ensures your forms always work—they just work better with JavaScript.


Next up: Forms create and update data. But how does the rest of the app know something changed? We’ll look at SvelteKit’s invalidation system and when to refresh data.