Progressive Enhancement
Overview
Progressive enhancement is the practice of building on a baseline that works everywhere, then layering improvements for capable browsers. In SvelteKit, forms work without JavaScript as standard HTML form submissions. Adding use:enhance upgrades them to submit via fetch, preventing full page reloads while keeping the no-JS fallback intact. This is not a bonus feature. It is the architecture.
use:enhance: The One-Line Upgrade
SvelteKit provides the enhance action from $app/forms. Adding it to a form makes submissions happen via fetch instead of a full page navigation.
<script lang="ts">
import { enhance } from '$app/forms';
let { data, form } = $props();
</script>
<form method="POST" action="?/create" use:enhance>
<input name="title" value={form?.title ?? ''} />
<textarea name="content">{form?.content ?? ''}</textarea>
<button type="submit">Create Post</button>
</form>
Without use:enhance, submitting this form triggers a full page reload. The browser sends a POST request, the server runs the action, and the entire page HTML is returned and re-rendered. It works, but the user sees a flash.
With use:enhance, the form submits via fetch in the background. The page updates in place. The URL stays the same. The scroll position is preserved. The user experience is seamless.
What use:enhance Does by Default
When you add use:enhance with no arguments, SvelteKit handles the common cases automatically.
On submit:
1. Resets the form element (if the action succeeds)
2. Invalidates all data (re-runs load functions)
3. Updates the page with new data
4. Handles redirects via client-side navigation
5. Renders the nearest +error.svelte on unexpected errors
On failure (action returns fail()):
1. Does NOT reset the form
2. Updates the form prop with the returned data
3. Does NOT invalidate data
This default behavior is correct for most forms. You do not need to write any fetch logic, handle loading states, or manage errors. It just works.
Custom Enhance Functions
For more control, pass a function to use:enhance. This function receives a cancel and formData on submission and returns a callback that runs after the server responds.
Loading States
<script lang="ts">
import { enhance } from '$app/forms';
let { data, form } = $props();
let submitting = $state(false);
</script>
<form
method="POST"
action="?/create"
use:enhance={() => {
submitting = true;
return async ({ update }) => {
await update();
submitting = false;
};
}}
>
<input name="title" value={form?.title ?? ''} disabled={submitting} />
<button type="submit" disabled={submitting}>
{submitting ? 'Creating...' : 'Create Post'}
</button>
</form>
The function passed to use:enhance runs when the form is submitted. It returns a callback that runs when the server responds. The update() function applies the default behavior (invalidation, form prop update, etc.).
Optimistic UI
Optimistic UI updates the interface immediately before the server confirms the action. If the action fails, you roll back.
<script lang="ts">
import { enhance } from '$app/forms';
let { data } = $props();
let optimisticTodos = $derived([...data.todos]);
</script>
<form
method="POST"
action="?/create"
use:enhance={({ formData }) => {
const text = formData.get('text') as string;
// Immediately add the todo to the list
const tempId = crypto.randomUUID();
optimisticTodos.push({
id: tempId,
text,
completed: false,
pending: true
});
return async ({ result, update }) => {
if (result.type === 'success') {
// Server confirmed — let the real data replace our optimistic entry
await update();
} else {
// Server rejected — remove the optimistic entry
optimisticTodos = optimisticTodos.filter((t) => t.id !== tempId);
await update();
}
};
}}
>
<input name="text" placeholder="Add todo" />
<button type="submit">Add</button>
</form>
<ul>
{#each optimisticTodos as todo}
<li class:pending={todo.pending}>{todo.text}</li>
{/each}
</ul>
The todo appears instantly in the list. If the server returns an error, it disappears. The user perceives zero latency for successful operations.
Preventing Default Behavior
Sometimes you need full control over the response handling.
<form
method="POST"
action="?/subscribe"
use:enhance={() => {
return async ({ result, update }) => {
if (result.type === 'success') {
showToast('Subscribed successfully');
// Don't call update() — handle everything manually
} else if (result.type === 'failure') {
showToast('Subscription failed: ' + result.data?.message);
await update(); // Still update the form prop for error display
} else {
await update(); // Default handling for redirects, errors
}
};
}}
>
<input name="email" type="email" placeholder="your@email.com" />
<button>Subscribe</button>
</form>
Not calling update() skips all default behavior: no form reset, no invalidation, no page update. Use this when you want complete control.
Canceling Submissions
The cancel function prevents the form from being submitted at all.
<form
method="POST"
action="?/delete"
use:enhance={({ cancel }) => {
if (!confirm('Are you sure you want to delete this?')) {
cancel();
}
}}
>
<button type="submit">Delete Account</button>
</form>
Graceful Degradation: No-JS Fallback
The critical insight is that use:enhance is a progressive layer. Remove it, and the form still works. The browser submits the form as a normal POST request, the server processes it, and the full page reloads with the result.
<!-- This form works with or without JavaScript -->
<form method="POST" action="?/create" use:enhance>
<input name="title" required />
<button type="submit">Create</button>
</form>
With JavaScript enabled:
1. use:enhance intercepts the submit event
2. Sends fetch request in the background
3. Page updates without reload
4. Smooth, app-like experience
Without JavaScript:
1. Browser handles the form natively
2. Full POST request to the server
3. Server runs the action
4. Full page reload with new state
5. Still works. Data is saved. User is redirected.
This dual behavior requires no extra code. You write one form, one action, and the enhancement is layered on top.
Why Progressive Enhancement Matters
Accessibility
Screen readers and assistive technologies work reliably with standard HTML forms. Custom JavaScript form handling can break keyboard navigation, focus management, and ARIA announcements. Starting with native forms ensures a solid accessibility baseline.
<!-- Native form elements are inherently accessible -->
<form method="POST" action="?/contact" use:enhance>
<label for="name">Name</label>
<input id="name" name="name" required />
<label for="message">Message</label>
<textarea id="message" name="message" required></textarea>
<button type="submit">Send</button>
</form>
Resilience
JavaScript fails more often than developers think. Corporate firewalls strip scripts. Browser extensions interfere. Network conditions cause partial loads. CDN cache misses leave bundles unavailable. A form that works without JavaScript works in all of these scenarios.
Scenarios where JavaScript fails:
- Corporate proxy strips inline scripts
- User has slow 2G connection, JS bundle times out
- Browser extension blocks third-party scripts
- CDN has a regional outage
- Old browser doesn't support a modern syntax
- Script error in unrelated code breaks the page
In every case: the HTML form still submits.
SEO & Performance
Search engine crawlers may not execute JavaScript. Forms that work without JS are fully functional during crawl. Pages that rely on client-side form handling show empty states to crawlers.
Server-rendered forms also load faster. There is no JavaScript to parse and execute before the form becomes interactive. The form is ready the moment the HTML arrives.
Real-World Pattern: Multi-Step Form
<!-- src/routes/onboarding/+page.svelte -->
<script lang="ts">
import { enhance } from '$app/forms';
let { form } = $props();
let step = $state(form?.step ?? 1);
</script>
{#if step === 1}
<form method="POST" action="?/step1" use:enhance={() => {
return async ({ result, update }) => {
if (result.type === 'failure') {
await update();
} else {
step = 2;
}
};
}}>
<h2>Step 1: Basic Info</h2>
<input name="name" value={form?.name ?? ''} placeholder="Your name" />
{#if form?.errors?.name}<p class="error">{form.errors.name}</p>{/if}
<button type="submit">Next</button>
</form>
{:else if step === 2}
<form method="POST" action="?/step2" use:enhance={() => {
return async ({ result, update }) => {
if (result.type === 'failure') {
await update();
} else {
step = 3;
}
};
}}>
<h2>Step 2: Preferences</h2>
<select name="theme">
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
<button type="button" onclick={() => step = 1}>Back</button>
<button type="submit">Next</button>
</form>
{:else}
<h2>All done</h2>
<p>Welcome aboard.</p>
{/if}
Without JavaScript, each step submits and the server returns the next step's form (tracked via the step field in the response). With JavaScript, the transition is instant.
Common Pitfalls
- Assuming JavaScript is always available: Building forms that only work with JavaScript means building forms that sometimes do not work. Start with native form behavior, then enhance.
- Not calling update() in custom enhance: Forgetting
await update()means the form prop does not update, load functions do not re-run, and redirects do not happen. Always call it unless you have a specific reason not to. - Losing form state after enhancement: The default
use:enhanceresets the form on success. If you need to keep values in the form after submission, handle it in the custom enhance callback. - Optimistic UI without rollback: If you update the UI before the server confirms, always have a plan for failure. Remove the optimistic entry, show an error, or retry.
- Breaking native form validation: HTML attributes like
required,type="email", andpatternprovide built-in validation. Callingcancel()in enhance or usingnovalidatedisables this. Keep native validation when possible.
Key Takeaways
use:enhanceupgrades forms to submit via fetch without page reloads. Without it, the form still works as a standard HTML submission.- Custom enhance functions give you control over loading states, optimistic UI, toast notifications, and conditional behavior.
- The
update()function applies SvelteKit's default post-submission behavior. Call it unless you need to handle everything manually. - Progressive enhancement means the form works for everyone. JavaScript users get a better experience, but no-JS users are not locked out.
- Accessibility, resilience, and SEO are not nice-to-haves. They are the practical reasons progressive enhancement is the default in SvelteKit.