Skip to content
ExponaBLOG
SVELTE & SVELTEKIT · DAY 022 / 100

Progressive enhancement: real-world lessons and examples

Apps that work before JavaScript loads.

Expona Admin6 min read

Progressive enhancement in SvelteKit isn't a retro technique—it's a resilience pattern that lets your app work when JavaScript hasn't loaded yet, takes too long to parse, or fails silently on slow networks. We've learned the hard way that assuming JavaScript always runs is a bet you'll lose on production traffic.

In this guide, we'll walk through progressive enhancement: real-world lessons and examples, showing you how to build apps that start working immediately while JavaScript gracefully adds interactivity on top.

Forms submit natively

The foundation of progressive enhancement is native HTML form submission. Before JavaScript ever loads, a user should be able to fill in a form and submit it via standard HTTP POST. This happens for free in SvelteKit when you use the <form> element—no setup required.

Let's say you're building an e-commerce checkout. A basic form without any JavaScript:

typescript
<form method="POST" action="/checkout">
  <input type="email" name="email" required />
  <input type="text" name="fullname" required />
  <select name="country">
    <option value="US">United States</option>
    <option value="GB">United Kingdom</option>
  </select>
  <button type="submit">Continue</button>
</form>

On a slow 3G network where your JavaScript bundle takes 12 seconds to download, the user can submit this form in 2 seconds. Your server-side form action processes it:

typescript
// src/routes/checkout/+page.server.ts
import { fail, redirect } from '@sveltejs/kit';

export const actions = {
  default: async ({ request }) => {
    const data = await request.formData();
    const email = data.get('email');
    const fullname = data.get('fullname');
    const country = data.get('country');

    // Validate
    if (!email || !fullname) {
      return fail(400, { email, fullname, message: 'Missing fields' });
    }

    // Save to database
    await db.orders.create({ email, fullname, country });

    // Server-side redirect
    redirect(303, '/checkout/payment');
  }
};

The form works. No JavaScript. The user reaches the next page. This is your baseline resilience. On slow networks or in regions where JavaScript bundles fail to load, your business logic still runs.

use:enhance improves them

Once JavaScript hydrates, we wrap the form with the use:enhance directive to add polish without breaking the native flow. use:enhance intercepts form submissions and handles them via progressive enhancement—sending the request as JSON, staying on the page, and updating the UI in place.

Here's the same form, now with client-side feedback:

typescript
// src/routes/checkout/+page.svelte
import { enhance } from '$app/forms';
import { page } from '$app/stores';

let loading = false;
let success = false;

async function handleSubmit() {
  loading = true;
}
</script>

<form method="POST" use:enhance={() => {
  loading = true;
  return async ({ result }) => {
    if (result.type === 'redirect') {
      // Server redirected; navigate client-side
      goto(result.location);
    } else if (result.type === 'failure') {
      // Validation failed; show error
      success = false;
    } else if (result.type === 'success') {
      // Show success state
      success = true;
      setTimeout(() => goto('/checkout/payment'), 1000);
    }
    loading = false;
  };
}}>
  <input type="email" name="email" required />
  <input type="text" name="fullname" required />
  <select name="country">
    <option value="US">United States</option>
    <option value="GB">United Kingdom</option>
  </select>
  <button type="submit" disabled={loading}>
    {loading ? 'Processing...' : 'Continue'}
  </button>
  {#if success}
    <p>✓ Order saved</p>
  {/if}
</form>

What changed? Almost nothing in the HTML. The form still submits to the same action. But now:

  • The request becomes a fetch call instead of a full page reload
  • We show loading state and disable the button
  • Errors stay on the same page with context
  • Success states animate in
  • If JavaScript never loads, the form still works via HTTP POST

If the JavaScript bundle fails to load, the browser falls back to the native POST behavior. No error. No blank screen. It just works.

The same principle applies to navigation. A standard <a href="/products"> link works immediately. Once JavaScript loads, SvelteKit's router intercepts clicks and does client-side navigation—faster, no flash of white screen.

If your SvelteKit app uses data-sveltekit-preload-data, pages prefetch in the background. But the link itself works whether JavaScript is ready or not:

typescript
<nav>
  <a href="/">Home</a>
  <a href="/products" data-sveltekit-preload-data="hover">
    Products
  </a>
  <a href="/about">About</a>
</nav>

In a ride-hailing app, a user on a commuter train with spotty connectivity can click "Request ride" and the form submits via HTTP. The server creates the ride immediately. JavaScript loads 3 seconds later and adds real-time location updates. The ride is already created. The passenger is already on the list.

We've seen teams build elaborate client-side state machines that fall apart when hydration fails. Progressive enhancement sidesteps that risk. The critical path—form submission, navigation—doesn't depend on JavaScript at all.

Better accessibility and resilience

When you design for progressive enhancement, accessibility often improves as a side effect. Forms that work without JavaScript typically have clear labels and proper semantic HTML. Keyboard navigation works because you're not fighting custom event handlers.

We've found that progressive enhancement also catches a class of bugs early. If your form only works client-side, you might miss validation logic that should run on the server. If your navigation only works via JavaScript, you miss edge cases where the router fails.

Real resilience means your app works in 3 different states:

  1. No JavaScript: Forms POST, links navigate via HTTP
  2. JavaScript loaded, hydrating: Routes might be partially interactive
  3. JavaScript ready: Full client-side enhancements, instant feedback, prefetching

In a trading platform we worked on, the order submission form had to work even if the JavaScript bundle was corrupted. A trader on a poor connection didn't want to lose an order because the minified code had a syntax error. We made the form work server-side first, then layered on client-side validation and real-time price updates. When the bundle failed to parse (it happened once in production), traders could still place orders.

Accessibility testing also becomes more thorough. Screen reader users often disable JavaScript. Forms that work without it are forms that work for everyone.

Common mistakes

Hiding forms until JavaScript loads: Don't wrap your form in {#if browser} or wait for hydration. Render it in the initial HTML. Progressive enhancement means the form is ready immediately.

Relying on client-side validation only: Always validate on the server. Client-side validation is UX polish, not security. If a request reaches your action, assume the client-side checks were bypassed.

Using only custom submit handlers: If you override the form's submit event and rely on JavaScript to send data, the form doesn't work natively. Always POST to an action. Let use:enhance wrap it.

Ignoring the request in your action: Your form action receives the full FormData. Don't create separate JSON endpoints. A single action handles both native submissions and use:enhance calls.

Forgetting to redirect or return data: After a successful form submission, either redirect (telling the client to navigate) or return data. If you return nothing, use:enhance won't know whether to show a success state or retry. In a SaaS platform we worked on, the form action was silent on success, and the user couldn't tell whether their invoice was saved.

Key takeaways

  • Build forms that work via HTTP POST before you add JavaScript. Your baseline is a server-side form action that validates and responds.
  • use:enhance is the opt-in layer that makes forms feel instant without breaking the native flow. When JavaScript loads, the experience improves. When it doesn't, the form still works.
  • Links are progressive by default in SvelteKit. A standard <a> tag navigates via HTTP. JavaScript client-side navigation is an enhancement, not a requirement.
  • Test your forms and navigation in three states: with JavaScript disabled, with JavaScript loading, and with JavaScript fully loaded. You'll catch assumptions early.
  • Resilience and accessibility improve together. Forms that work without JavaScript are forms that work for everyone—slow networks, failing bundles, screen reader users, all of them.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles