Training a LoRA needs a curated batch of generated images. Most tools that do this for you assume a hosted backend: you hand over your provider API key, the server calls the provider on your behalf, and your data sits in someone else's database.
That's a trust and infrastructure liability that has nothing to do with the actual problem — generating and organizing a dataset. The goal was a tool where the architecture makes that liability impossible, not just avoided by policy.
┌───────────────────────────────────────────────┐
│ React 19 SPA (Vite) │
│ Dataset workspace · export · settings UI │
└──────────────────────┬──────────────────────────┘
▼
┌───────────────────────────────────────────────┐
│ Provider-Agnostic Generation Layer │
│ GenerationProvider interface │
│ ├─ FAL.ai adapter │
│ └─ WaveSpeed.ai adapter │
│ user's own API key — never leaves the browser │
└──────────────────────┬──────────────────────────┘
▼
┌───────────────────────────────────────────────┐
│ Local Storage Layer │
│ IndexedDB — dataset metadata, tags, state │
│ OPFS — binary image assets, near-native I/O │
└──────────────────────┬──────────────────────────┘
▼
┌───────────────────────────────────────────────┐
│ JSZip export → ready for LoRA training │
└───────────────────────────────────────────────┘
no server tier exists in this diagramThe SPA talks to a provider-agnostic generation interface, not to FAL.ai or WaveSpeed.ai directly. Every generated asset is written straight to OPFS from the browser; metadata is indexed in IndexedDB for fast queries. Export bundles the result with JSZip — still entirely client-side.
There is no request path in this system that passes through infrastructure the author controls. That's a deliberate constraint, not a missing feature.
FAL.ai and WaveSpeed.ai sit behind a single GenerationProvider interface. The user's key is passed at call time and held only in memory for that session — never logged, never sent anywhere but the provider the user chose.
// lib/generation/GenerationProvider.ts
interface GenerationProvider {
name: 'fal' | 'wavespeed'
generate(prompt: string, key: string): Promise<GeneratedImage>
}
// The UI depends on this interface only — swapping
// FAL.ai for WaveSpeed.ai (or adding a third provider)
// never touches a single component.
const provider: GenerationProvider =
useProviderStore((s) => s.activeProvider)Binary image data goes to the Origin Private File System for near-native file I/O. Small, queryable metadata — tags, timestamps, dataset membership — goes to IndexedDB, kept lean on purpose.
Splitting the two means a large dataset never turns IndexedDB into an unwieldy blob store.
// lib/storage/assetStore.ts
async function saveGeneratedImage(blob: Blob, meta: ImageMeta) {
const root = await navigator.storage.getDirectory()
const handle = await root.getFileHandle(meta.id, { create: true })
const writable = await handle.createWritable()
await writable.write(blob) // OPFS: near-native file I/O
await writable.close()
await db.images.put(meta) // IndexedDB: queryable metadata only
}With zero server-side validation, correctness has to be proven entirely client-side: Vitest covers storage adapters and the provider abstraction, Playwright covers the full generate → review → export user flow.
396 unit tests across 69 files, 28 E2E tests across 6 specs — sourced from the project's own README rather than re-measured here.
A dataset-generation SaaS holding user API keys is a standing liability — a breach exposes every connected account. BYOK means the key never leaves the tab it was typed into.
No server-side rate limiting or usage dashboards to offer — the user manages their own provider quota directly.
IndexedDB can technically hold blobs, but OPFS gives near-native file I/O for hundreds of generated images without ballooning the database file.
Two storage systems to keep consistent instead of one — a failed write to either layer has to be treated as a partial-write case.
Most of the app is local component state by design. Zustand exists for exactly the handful of values — active provider, active dataset — that genuinely need to be global.
Slightly more prop drilling in a few places than a single global store would require, in exchange for not centralizing state that had no business being global.
A backend-optional architecture tends to grow a de facto backend dependency over time. Committing to zero backend from the start forced every feature to be provably client-side.
No server-side job queue for long-running generation batches — retry and resume logic all lives in the browser tab.
Generation, storage, and export all run client-side — nothing to host, patch, or lose data from
Across 69 files, covering storage adapters, provider abstraction, and dataset logic
Across 6 spec files, exercising the generation → review → export flow end to end
BYOK isn't a checkbox feature bolted onto a normal SaaS — it's a constraint that reshapes every layer underneath it. No backend meant no shortcuts: storage, retries, and export all had to work with only what the browser provides, and the test suite had to carry the weight a server's validation layer usually would.
The same provider-abstraction instinct — one interface, swappable backend — is what makes the AI Lab pipeline's dual-backend generation layer work, too. It's a pattern, not a one-off.
FOR FREELANCE / PROJECT CLIENTS
Zero-backend, BYOK architecture means your data never touches my infrastructure — no hosting bill, no breach surface, one less thing to audit before you sign off.
FOR FULL-TIME / HIRING TEAMS
Provider-abstraction and offline-first patterns transfer directly to production systems — 396 unit tests and 28 E2E specs are how I replace a server's validation layer when there isn't one.