Progressive Enhancement in SvelteKit Forms Guide

Progressive enhancement is one of those web development principles that sounds old-school until you realize how much it improves reliability and user experience. In SvelteKit, it's not just a nice-to-have; it's built into the framework's DNA. Forms that work server-first, then enhance on the client, eliminate entire categories of bugs and give your users a working app even if their JavaScript fails to load or fails partway through.
Over the past year, I've shipped several SvelteKit apps using progressive enhancement for forms, and the payoff has been real: fewer support tickets about "my form submission didn't work," better performance metrics, and frankly, less defensive coding. This guide walks through the practical patterns I've refined.
Link to section: Starting with Server-Rendered FormsStarting with Server-Rendered Forms
A SvelteKit form starts simple. You define a server action in a file like src/routes/contact/+page.server.ts, and the browser sends data to it.
// src/routes/contact/+page.server.ts
import { fail, redirect } from '@sveltejs/kit';
import type { Actions } from './$types';
const schema = {
name: (v) => typeof v === 'string' && v.length > 0 ? null : 'Name is required',
email: (v) => typeof v === 'string' && v.includes('@') ? null : 'Valid email required',
message: (v) => typeof v === 'string' && v.length > 10 ? null : 'Message too short'
};
export const actions: Actions = {
default: async ({ request }) => {
const data = await request.formData();
const name = data.get('name') as string;
const email = data.get('email') as string;
const message = data.get('message') as string;
const errors: Record<string, string> = {};
if (schema.name(name)) errors.name = schema.name(name);
if (schema.email(email)) errors.email = schema.email(email);
if (schema.message(message)) errors.message = schema.message(message);
if (Object.keys(errors).length > 0) {
return fail(400, { errors, name, email, message });
}
// Save to database or send email
redirect(303, '/thank-you');
}
};The form itself lives in the page component and doesn't require any JavaScript to function.
<!-- src/routes/contact/+page.svelte -->
<script>
import type { ActionData } from './$types';
let { form }: { form?: ActionData } = $props();
</script>
<form method="POST">
<label for="name">Name</label>
<input type="text" id="name" name="name" value={form?.name || ''} />
{#if form?.errors?.name}
<span class="error">{form.errors.name}</span>
{/if}
<label for="email">Email</label>
<input type="email" id="email" name="email" value={form?.email || ''} />
{#if form?.errors?.email}
<span class="error">{form.errors.email}</span>
{/if}
<label for="message">Message</label>
<textarea id="message" name="message">{form?.message || ''}</textarea>
{#if form?.errors?.message}
<span class="error">{form.errors.message}</span>
{/if}
<button type="submit">Send</button>
</form>
<style>
.error {
color: #d32f2f;
font-size: 0.875rem;
margin-top: 0.25rem;
}
</style>At this point, your form works. No JavaScript needed. User fills it out, hits submit, the server validates, returns errors if needed, or redirects on success. The entire experience works without a single kilobyte of client-side JavaScript.
Link to section: Adding Client-Side EnhancementAdding Client-Side Enhancement
Once you've confirmed the server path works, you layer on client-side features. I use Superforms for this. It provides validation, optimistic UI, and loading states all tied into the server response.
Form validation with Superforms handles the glue between your server logic and client interactivity. Install it first:
npm install -D sveltekit-superforms zodDefine your schema at the module level using Zod. This is critical: keep it outside your load or action functions so Superforms can cache the adapter.
// src/routes/contact/+page.server.ts
import { superValidate, fail, redirect } from 'sveltekit-superforms';
import { zod } from 'sveltekit-superforms/adapters';
import { z } from 'zod';
const contactSchema = z.object({
name: z.string().min(1, 'Name is required'),
email: z.string().email('Valid email required'),
message: z.string().min(10, 'Message too short')
});
export const load = async () => {
const form = await superValidate(zod(contactSchema));
return { form };
};
export const actions = {
default: async ({ request }) => {
const form = await superValidate(request, zod(contactSchema));
if (!form.valid) {
return fail(400, { form });
}
// Save to database
redirect(303, '/thank-you');
}
};Then on the client, use the Superforms store to get reactive data binding and validation.
<!-- src/routes/contact/+page.svelte -->
<script lang="ts">
import { superForm } from 'sveltekit-superforms';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
const { form, enhance, submitting } = superForm(data.form);
</script>
<form method="POST" use:enhance>
<label for="name">Name</label>
<input
type="text"
id="name"
name="name"
bind:value={$form.name}
/>
{#if $form.name && $errors.name}
<span class="error">{$errors.name}</span>
{/if}
<label for="email">Email</label>
<input
type="email"
id="email"
name="email"
bind:value={$form.email}
/>
{#if $form.email && $errors.email}
<span class="error">{$errors.email}</span>
{/if}
<label for="message">Message</label>
<textarea
id="message"
name="message"
bind:value={$form.message}
></textarea>
{#if $form.message && $errors.message}
<span class="error">{$errors.message}</span>
{/if}
<button disabled={$submitting}>
{$submitting ? 'Sending...' : 'Send'}
</button>
</form>The use:enhance directive intercepts the form submission and handles it without a full page reload. It updates the store reactively, shows loading state, and handles errors. But critically: if JavaScript doesn't load, the form still submits normally to the server and posts data the traditional way.

Link to section: Validation StrategiesValidation Strategies
Superforms supports multiple validation libraries. Zod is popular, but Valibot and TypeBox are leaner. The principle is the same: define your schema once, share it between server and client.
I often run server-side validation only initially, then add client-side real-time validation after the form is populated. This avoids hammering users with error messages as they type.
// Validate on blur, not on every keystroke
export const contactSchema = z.object({
name: z.string()
.min(1, 'Name is required')
.min(2, 'Name too short'),
email: z.string()
.email('Invalid email address'),
message: z.string()
.min(10, 'At least 10 characters')
.max(5000, 'Max 5000 characters')
});
// Client-side validation config
const { form, errors, validate } = superForm(data.form, {
validators: zod(contactSchema),
taintedMessage: 'You have unsaved changes'
});The taintedMessage option warns users if they leave the page with unsaved changes. This prevents accidental data loss, a small detail that feels professional.
Link to section: Handling File Uploads ProgressivelyHandling File Uploads Progressively
File uploads need special care. FormData serialization doesn't work with Superforms out of the box, but you can configure it.
// src/routes/upload/+page.server.ts
import { superValidate, fail } from 'sveltekit-superforms';
import { zod } from 'sveltekit-superforms/adapters';
import { z } from 'zod';
const uploadSchema = z.object({
file: z.instanceof(File)
.refine((f) => f.size < 5_000_000, 'File must be under 5MB')
.refine(
(f) => ['image/jpeg', 'image/png', 'application/pdf'].includes(f.type),
'File type not allowed'
)
});
export const actions = {
default: async ({ request }) => {
const form = await superValidate(request, zod(uploadSchema));
if (!form.valid) {
return fail(400, { form });
}
const file = form.data.file;
const buffer = await file.arrayBuffer();
// Store or process buffer
return { success: true };
}
};In the component, bind the file input to a proxy that Superforms manages:
<script lang="ts">
import { superForm, fileProxy } from 'sveltekit-superforms';
const { form, enhance } = superForm(data.form);
const file = fileProxy(form, 'file');
</script>
<form method="POST" enctype="multipart/form-data" use:enhance>
<label for="file">Upload</label>
<input
type="file"
id="file"
bind:files={$file}
/>
{#if $errors.file}
<span class="error">{$errors.file}</span>
{/if}
<button>Upload</button>
</form>The file input works without JavaScript; it just sends the file to the server. With JavaScript, you get client-side validation, progress feedback, and a nicer experience.
Link to section: Error Boundaries and Graceful DegradationError Boundaries and Graceful Degradation
SvelteKit 5 introduced <svelte:boundary> for error handling. If something goes wrong in a form submission or validation, the boundary catches it and shows a fallback.
<svelte:boundary>
<form method="POST" use:enhance>
<!-- form fields -->
</form>
{#snippet failed(error, reset)}
<div class="error-box">
<p>Something went wrong: {error.message}</p>
<button onclick={reset}>Try again</button>
</div>
{/snippet}
</svelte:boundary>This prevents your entire page from breaking if a single form component has a bug. Without it, one runtime error can orphan users in a broken state.
Link to section: Multi-Step Forms and State PreservationMulti-Step Forms and State Preservation
Longer forms often break across multiple pages. Use SvelteKit's built-in snapshot feature to save form state in the browser's history so users can navigate back without losing their work.
<script lang="ts">
import { page } from '$app/state';
const { form, enhance } = superForm(data.form);
export const snapshot = {
capture: () => ({
name: $form.name,
email: $form.email,
message: $form.message
}),
restore: (snapshot) => {
form.set(snapshot);
}
};
</script>When the user navigates away and comes back, the snapshot restores their input automatically.
Link to section: Testing Progressive FormsTesting Progressive Forms
Test both paths: with and without JavaScript. Vitest with jsdom handles server-side validation, while Playwright tests the actual form submission and client behavior.
// contact.test.ts
import { test, expect } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import ContactPage from './+page.svelte';
test('form renders without errors', () => {
render(ContactPage, { props: { form: {} } });
expect(screen.getByLabelText('Name')).toBeInTheDocument();
});
test('validation shows errors on invalid input', async () => {
const { container } = render(ContactPage, {
props: { form: { errors: { name: 'Name is required' } } }
});
expect(container.textContent).toContain('Name is required');
});And with Playwright, test the actual form submission end-to-end:
// contact.e2e.ts
import { test, expect } from '@playwright/test';
test('form submits successfully', async ({ page }) => {
await page.goto('/contact');
await page.fill('input[name="name"]', 'John Doe');
await page.fill('input[name="email"]', 'john@example.com');
await page.fill('textarea[name="message"]', 'This is a test message');
await page.click('button:has-text("Send")');
await expect(page).toHaveURL('/thank-you');
});
test('shows validation errors without submitting', async ({ page }) => {
await page.goto('/contact');
await page.click('button:has-text("Send")');
await expect(page.locator('.error')).toContainText('Name is required');
});Link to section: Performance and Real-World Trade-offsPerformance and Real-World Trade-offs
I've found that validation schemas can become a bottleneck if you parse complex nested objects. Zod 3 improved performance significantly, but if you're validating large arrays or deeply nested structures, consider splitting validation into smaller chunks.
One project I worked on had a form with 50 fields across multiple sections. Validating all at once on every keystroke caused noticeable lag. The fix: validate per-section rather than the entire form. This reduced validation time from 45ms to 8ms on my MacBook.
Also, keep Superforms initialization outside event handlers. Reinitialization on every form submission doubles the validation overhead. Initialize once at component mount and reuse the store.
Link to section: Patterns Worth StealingPatterns Worth Stealing
Pattern 1: Conditional fields Show or hide fields based on other form values. This works server-first; JavaScript just makes the UX smoother.
{#if $form.accountType === 'business'}
<label for="company">Company Name</label>
<input type="text" id="company" name="company" bind:value={$form.company} />
{/if}Pattern 2: Async field validation Check if an email is already registered. Run this on the server inside the action or in a separate endpoint.
export const actions = {
checkEmail: async ({ request }) => {
const formData = await request.formData();
const email = formData.get('email');
const exists = await db.user.findUnique({ where: { email } });
return { exists: !!exists };
}
};Pattern 3: Optimistic updates Update the UI immediately before the server responds, then sync if the server returns different data.
const { enhance } = superForm(data.form, {
onSubmit: ({ formData }) => {
// Show success immediately
showToast('Message sent!');
},
onResult: ({ result }) => {
// Sync with server result
if (!result.data.success) {
showToast('Error sending message', 'error');
}
}
});Link to section: Accessibility ConsiderationsAccessibility Considerations
Forms must be keyboard accessible. Use proper labels, for attributes, and tab order. Svelte doesn't add extra overhead here; just follow semantic HTML.
<label for="email">Email (required)</label>
<input
type="email"
id="email"
name="email"
aria-required="true"
aria-invalid={$errors.email ? 'true' : 'false'}
aria-describedby={$errors.email ? 'email-error' : undefined}
bind:value={$form.email}
/>
{#if $errors.email}
<span id="email-error" role="alert">{$errors.email}</span>
{/if}The aria-invalid and aria-describedby attributes link error messages to fields for screen readers.
Link to section: Next StepsNext Steps
- Define your server action first and validate with plain JavaScript.
- Add Superforms and test the client-side flow.
- Wrap forms in
<svelte:boundary>for error isolation. - Test both no-JS and full-JS paths in Playwright.
- Monitor form submission failures in production; use OpenTelemetry or Sentry to catch unexpected errors.
Progressive enhancement isn't extra work; it's better architecture. You build the solid foundation first, then layer on delightful enhancements. Your users get a working app either way, and you sleep better knowing your forms are resilient.

