Quote wizard with progressive enhancement
The first real flow on the customer portal: a three-field quote form that works without JavaScript, with `use:enhance` upgrading it to client-side navigation when JS is available. SvelteKit form actions + the BFF helper. Slice 19 in the insurance-app repo.
The customer portal has a header, a session, and a BFF. Time for the first real flow. The “Get a quote” page is the easiest one to write and the most subtle, because the right answer involves a SvelteKit idiom most React-trained engineers don’t reach for by default: HTML form actions with progressive enhancement.
Companion commit: 47cd8ff in insurance-app.
Why progressive enhancement, not “submit then re-render”
The dominant frontend pattern in 2026 is still: form posts to a JSON endpoint, JavaScript prevents-default, fetches, and re-renders. That works — and it also leaves a page that does nothing with JavaScript disabled, slow networks, or a hydration race condition. For a quote form, where “nothing” means a prospective customer can’t get a price, that’s a bad failure mode.
SvelteKit’s form actions invert the default. The page is a plain
<form method="POST"> that the server already knows how to handle.
use:enhance then upgrades it: when JS is loaded, it intercepts the
submit, posts in the background, and applies the returned form blob
to the page without a navigation. Both code paths run the same server
action — that’s the load-bearing detail.
<form method="POST" use:enhance>
<input name="vehicleVin" required maxlength="17" />
<input name="driverAge" type="number" required min="16" max="99" />
<select name="coverageType">…</select>
<button>Calculate premium</button>
</form>
Disable JavaScript in DevTools, submit the form: the page reloads with the quote tile filled in. Re-enable, submit again: same page, no reload, same data. One contract.
The form action
+page.server.ts exports a default action. SvelteKit calls it for any
POST to the page route. Validation, normalization, BFF call, and
error mapping all live in one function:
// gui/customer-app/src/routes/quote/+page.server.ts
import { fail, type Actions } from '@sveltejs/kit';
import { libertyJson } from '$lib/server/liberty';
export const actions: Actions = {
default: async ({ request, locals }) => {
const data = await request.formData();
const values = {
vehicleVin: String(data.get('vehicleVin') ?? '').trim(),
driverAge: String(data.get('driverAge') ?? '').trim(),
coverageType: String(data.get('coverageType') ?? '').trim(),
};
if (!values.vehicleVin || !values.driverAge || !values.coverageType) {
return fail(400, { values, error: 'All three fields are required.' });
}
const age = parseInt(values.driverAge, 10);
if (Number.isNaN(age) || age < 16 || age > 99) {
return fail(400, { values, error: 'Driver age must be between 16 and 99.' });
}
if (!['BASIC', 'STANDARD', 'PREMIUM'].includes(values.coverageType)) {
return fail(400, { values, error: 'Coverage must be BASIC, STANDARD, or PREMIUM.' });
}
const session = await locals.auth();
const opts = session?.user
? { userId: session.user.id ?? undefined, userEmail: session.user.email ?? undefined }
: {};
try {
const quote = await libertyJson<Quote>('POST', '/api/quotes', {
body: { vehicleVin: values.vehicleVin, driverAge: age, coverageType: values.coverageType },
...opts,
});
return { values, quote };
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
if (msg.includes('429')) {
return fail(429, { values, error: 'Too many quotes for this VIN in the last minute. Try a different VIN or wait a bit.' });
}
return fail(502, { values, error: msg });
}
},
};
Four things are happening that won’t be obvious from the code alone:
return { values, quote }is the success path. The returned object becomesformon the page. Withoutuse:enhancethe browser re-navigates and the newformis rendered server-side; with it, SvelteKit applies the result client-side. Same shape either way.fail(400, …)returns the same shape with an HTTP error status. The form gets re-rendered withform.errorset and the previously- typed values pre-filled. Validation feedback comes free.valuesalways echoes the submitted form fields, including on failure. This is what makes a form feel right after a validation error — the user doesn’t have to re-type anything.- Anonymous-by-default but identity-aware. Get-a-quote precedes
login, matching the typical insurer flow. If a session does happen
to exist, the BFF propagates
X-User-Id/X-User-Emailso the audit log captures it.
Rendering the result
The Svelte side reads both data (server-rendered page data) and
form (action result). Svelte 5’s $props() rune unpacks both at
once:
<script lang="ts">
import { enhance } from '$app/forms';
import type { ActionData, PageData } from './$types';
let { data, form }: { data: PageData; form: ActionData } = $props();
</script>
{#if form?.error}
<div class="bg-red-50 border-l-4 border-red-600 p-4 text-red-900 text-sm">
<strong>Couldn't calculate a quote.</strong> {form.error}
</div>
{/if}
{#if form?.quote}
<div class="bg-emerald-50 border border-emerald-200 rounded-lg p-6">
<p class="text-4xl font-semibold text-emerald-900">${form.quote.premium}</p>
<p class="text-xs text-emerald-700">valid until {form.quote.validUntil}</p>
<a href={`/policies/bind?quoteId=${form.quote.id}`}>Accept and bind</a>
</div>
{/if}
The pre-fill happens with value={form?.values?.vehicleVin ?? ''} on
each <input>. After a validation error, the form re-renders with the
exact strings the user submitted. Type-coercion (string → number for
age) happens server-side in the action; the input keeps the original
text.
The teaching moment students remember
The handout for this chapter explicitly asks students to disable JavaScript and submit the form. Two things they invariably comment on:
- The page reload “feels slow” the first time and “feels normal” the second time. (Browser caching the static assets.)
- The form works. Most have never seen a 2026 frontend that does this.
Then re-enable JS and submit again. Same form, same result, no flash.
The pattern hides three things — fetch + JSON + state update — behind
six characters of Svelte (use:enhance).
Pulling the rate-limit thread
The '429' error branch in the action surfaces a slice-1-through-5
behavior the rest of the curriculum talked about but couldn’t show
without a UI. The quote endpoint has a sliding-window rate limit per
VIN: 5 quotes/minute. Submit the same VIN six times in a row:
for i in 1 2 3 4 5 6 7; do
curl -s -o /tmp/r -w "%{http_code} " -X POST http://localhost:3000/quote \
-d "vehicleVin=1HGBH41JXMN109186&driverAge=35&coverageType=STANDARD"
done
echo
# 200 200 200 200 200 429 429
In the UI, that becomes the red box: “Too many quotes for this VIN in the last minute.” The cache-then-rate-limit chain isn’t a story anymore; it’s a thing students see.
What about client-side validation?
Deliberately absent for this slice. HTML5 validation (required,
maxlength="17", min="16") covers 90% of bad input before it ever
hits the server, and the server validation in the action covers the
rest. Adding a JavaScript validation library would double-encode the
rules — a recipe for the two layers drifting apart silently.
The pattern to copy in later forms: HTML5 validation in the markup, authoritative validation server-side, no JavaScript validation library.
Verify
Open https://my.insurance-app.comptech-lab.com/quote (assuming
chapter 27 is done; if not,
http://localhost:3000/quote). DevTools → Network:
POST /quotereturns200with a SvelteKit form-action body.- The response body is something like
{"type":"success","status":200,"data":"[{...}]"}— that’s the serialized action result withvalues+quote. - Disable JS; resubmit. The
POST /quotenow returns a full HTML document (status 200) with the quote tile inlined. Same data, different envelope. - Submit the same VIN six times; the sixth comes back with the 429 error box visible.
What you have
- A real flow on the customer portal, working with or without JavaScript.
- The SvelteKit form-actions +
use:enhancepattern, which subsequent chapters reuse without ceremony. - Validation rules expressed once on the server.
- The first concrete demonstration that the slice-1-through-5 Quote service (Redis cache, rate limit, MI mediation) is actually behaving as advertised, from the UI side.
Next: 25 — Policies, bind, and the visible Idempotency-Key →