Claim filing through a BFF (+ polish)
Multipart upload from the browser to SvelteKit to Liberty: rebuild the FormData server-side so you control exactly what Liberty sees. Plus the polish pass — real account page, error pages, breadcrumbs. Slices 21 + 22 in the insurance-app repo.
Customers can quote, bind, and pay. They cannot yet file a claim — the
one operation that involves an actual file upload. The chapter-22
vanilla tour uploaded straight to Liberty’s /api/claims from the
browser using fetch + FormData. The customer portal does the same
job through the BFF, which is slightly different in a way worth
spelling out.
This chapter also folds in slice 22’s polish pass — a real account page, a custom error page, breadcrumbs — because each of those is one SvelteKit idiom and they don’t earn their own chapters individually.
Companion commits: 495febf5 (slice 21 — claim filing), e54eed01
(slice 22 — polish).
Multipart through a BFF — the failure mode you don’t see coming
Naive multipart proxying through a Node middle tier works on the happy path and breaks at every edge. The two failure modes worth knowing about:
- You parse the incoming form, then re-stream it. The parser eats the file’s bytes into a buffer; for a 50MB photo that’s 50MB of memory you didn’t budget for, per concurrent request.
- You hot-pipe the request body through. Now the boundary header,
the order of parts, the way the upstream parser interprets your
middle-tier additions are all interlocked. Liberty’s
@FormParam-drivenClaimResourceis happy to reject the whole thing with a 500 if anything is off-spec.
The middle ground we adopt: parse on the way in, rebuild on the way
out. The Node side knows what Liberty’s parts are named, so it
builds a fresh FormData with exactly those names:
// gui/customer-app/src/routes/claims/file/+page.server.ts
const incoming = await request.formData();
const policyNumber = String(incoming.get('policyNumber') ?? '').trim();
const description = String(incoming.get('description') ?? '').trim();
const otherPartyVin = String(incoming.get('otherPartyVin') ?? '').trim();
const file = incoming.get('attachment');
if (!policyNumber) {
return fail(400, { values: { policyNumber, description, otherPartyVin },
error: 'Policy number is required.' });
}
const outgoing = new FormData();
outgoing.append('policyNumber', policyNumber);
if (description) outgoing.append('description', description);
if (otherPartyVin) outgoing.append('otherPartyVin', otherPartyVin);
if (file instanceof File && file.size > 0) {
outgoing.append('attachment', file, file.name);
}
const res = await liberty('POST', '/api/claims', {
body: outgoing,
userId: session.user.id ?? undefined,
userEmail: session.user.email ?? undefined,
});
The liberty() BFF helper from
chapter 23 skips its
auto-JSON branch when it sees a FormData — the body passes through
untouched, the browser/Node fetch implementation sets the Content-Type: multipart/form-data; boundary=... header on its own, and Liberty’s
multipart parser sees clean text + binary parts with the part names it
expects.
The file size check is the unsung guard: incoming.get('attachment')
returns a File even for an empty submission — file.size > 0 is the
right filter, not file != null.
The form
<input type="file"> doesn’t need enctype="multipart/form-data" if
you write enctype="multipart/form-data" on the <form>. SvelteKit’s
form-actions handle it as-is. The form itself is one block:
<form method="POST" enctype="multipart/form-data" use:enhance>
<label>Policy number
<input name="policyNumber" required value={data.prefillPolicy} />
</label>
<label>Description
<textarea name="description" rows="3" />
</label>
<label>Other party VIN (optional)
<input name="otherPartyVin" />
</label>
<label>Photo of damage
<input name="attachment" type="file" accept="image/*" />
</label>
<button>File the claim</button>
</form>
data.prefillPolicy comes from load() reading url.searchParams.get('policy'),
so the “File a claim” CTA on the policy detail page passes the
policy number as a query string and the form pre-fills.
The successful submit → claim detail
The action’s last line throws a 303 redirect to the new claim detail page:
const claim = (await res.json()) as Claim;
throw redirect(303, `/claims/${claim.id}`);
/claims/[id]/+page.server.ts GETs that one claim through the BFF.
The detail page renders the OCR text panel (slice 9), the partner-
enrichment panel (slice 10), and the status badge. That whole panel
turns up because the slice-10 claim flow synchronously enriches with
OCR + partner data before returning; the BFF doesn’t have to poll.
Polish pass — slice 22
Three independent pieces, none of which earn a chapter on their own.
Account page
/account was a stub. Slice 22 fills it in: profile card (name,
email, user id), two activity panels (recent policies + recent
claims), a sign-out form. Both panels fetch through the BFF and render
into status-badged lists.
The page is honest about its limitation in a paragraph at the bottom:
The recent-activity panels currently surface system-wide rows because Liberty’s policy/claim records do not yet store an owning user id — adding one is a one-line change in
PolicyService/ClaimServiceplus a column migration, after which the BFF can filter byX-User-Idbefore rendering this page.
That paragraph is intentional teaching content. A real customer portal would absolutely filter by user; the artifact doesn’t yet, and saying so out loud is better than quietly mis-leading the demo audience.
Error page (+error.svelte)
SvelteKit’s default error page is functional and ugly. A custom
+error.svelte replaces it for any thrown error(...) from a
load() or action:
<script lang="ts">
import { page } from '$app/state';
const status = $derived(page.status);
const message = $derived(page.error?.message ?? 'Something went wrong.');
</script>
<section class="text-center max-w-xl mx-auto">
<p class="text-6xl font-bold text-slate-300 font-mono">{status}</p>
<h1 class="text-2xl font-semibold">
{#if status === 404}Page not found
{:else if status >= 500}Something broke on our end
{:else}{message}
{/if}
</h1>
<p>{message}</p>
<a href="/">Home</a> · <a href="/quote">Get a quote</a>
</section>
The status code in giant grey type, a tailored heading per error
class, and two CTAs back to known-good pages. Drop it once in src/routes/
and it covers every error in every nested route.
Breadcrumbs
Auto-generated from page.url.pathname, lives in +layout.svelte,
takes about 25 lines:
<script lang="ts">
import { page } from '$app/state';
interface Crumb { label: string; href: string | null; }
function pretty(seg: string): string {
if (/^POL-[A-F0-9]+$/.test(seg)) return seg; // policy numbers — verbatim
if (/^\d+$/.test(seg)) return `#${seg}`; // numeric ids — prefix with #
return seg.charAt(0).toUpperCase() + seg.slice(1);
}
const crumbs: Crumb[] = $derived.by(() => {
const segs = page.url.pathname.split('/').filter(Boolean);
if (segs.length === 0) return [];
const out: Crumb[] = [{ label: 'Home', href: '/' }];
let acc = '';
segs.forEach((seg, i) => {
acc += '/' + seg;
out.push({ label: pretty(seg), href: i === segs.length - 1 ? null : acc });
});
return out;
});
</script>
The pretty() function is the one piece a sweep-the-path crumbs
helper always forgets: route segments aren’t all human-readable as-is.
Policy numbers stay verbatim; numeric ids get a # prefix; everything
else gets title-cased.
Verify
# File a claim through the portal, end to end (signed-in cookies)
curl -s -b cookies.txt -X POST http://localhost:3000/claims/file \
-F "policyNumber=POL-AB12CD" \
-F "description=Rear-ended in parking lot" \
-F "attachment=@/tmp/photo.jpg" \
-o /dev/null -w "%{http_code} %{redirect_url}\n"
# 303 http://localhost:3000/claims/47
# The redirect target renders OCR text from MI + partner panel if otherPartyVin given
curl -s -b cookies.txt http://localhost:3000/claims/47 | grep -o 'OCR.*</' | head -1
# Custom 404 page renders
curl -s http://localhost:3000/does-not-exist | grep -c "Page not found"
# 1
Smoke script’s section 20 grows by three checks for the 404 body and the breadcrumb markup; the suite hits 193/0.
What you have
- A working claim-filing flow through the BFF with multipart upload — the BFF parses incoming, rebuilds outgoing, and lets Liberty’s multipart parser see exactly the parts it expects.
- The pattern for any future file upload: name parts deliberately on both sides, don’t hot-pipe.
- A real /account page that shows recent activity and is honest about what it can’t yet filter.
- A custom error page that covers every 404 and 500 in the portal.
- Auto-breadcrumbs that handle policy numbers and numeric ids gracefully.
The portal is now a complete customer surface — sign in, get a quote,
bind, pay, file a claim, see your stuff in /account. What’s missing is
that you’ve been running it all on localhost:3000 for two chapters.
Slice 23 puts it on a real public URL through HAProxy + Cloudflare,
which surfaces three load-bearing OIDC config details that don’t show
up until they bite.