03 — LORA STUDIO / 2025

BRING YOUR OWN KEY,
KEEP YOUR DATA

A React 19 dataset generator for LoRA training with no backend at all — the user's own provider API key drives generation, and every image is stored locally via IndexedDB and OPFS.

ROLE
Solo Architect & Developer
YEAR
2025
STACK
React 19 + TypeScript + IndexedDB/OPFS
ARCHITECTURE
100% client-side, zero backend
01 — CONTEXT & PROBLEM

A TOOL SHOULDN'T HOLD KEYS IT DOESN'T NEED

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.

If there's no server, there's nothing to breach and nothing to trust.
02 — ARCHITECTURE

NO BACKEND IS THE ARCHITECTURE, NOT THE ABSENCE OF ONE

┌───────────────────────────────────────────────┐
│              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 diagram

The 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.

The same one-interface-multiple-backends pattern shows up again in the AI Lab pipeline — swap the provider, keep the UI.
03 — IMPLEMENTATION

THREE KEY DECISIONS

A — BYOK PROVIDER ABSTRACTION

One interface, two interchangeable providers

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
// 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)
B — OPFS + INDEXEDDB SPLIT

The right storage primitive for each kind of data

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
// 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
}
C — TESTS AS THE SAFETY NET A SERVER WOULD HAVE BEEN

No backend to catch mistakes — so the test suite has to

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.

RTL and MSW mock the provider network boundary — the one point where this system does talk to the outside world.
04 — TRADEOFFS

DECISIONS & THEIR COSTS

BYOK over hosted, server-held API keys

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.

OPFS for binary assets, IndexedDB for metadata

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.

Zustand only where state crosses component boundaries

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.

Zero backend, full stop — not "backend-optional"

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.

05 — OUTCOMES

ZERO INFRASTRUCTURE, FULL COVERAGE

0
Backend servers

Generation, storage, and export all run client-side — nothing to host, patch, or lose data from

396
Vitest unit tests

Across 69 files, covering storage adapters, provider abstraction, and dataset logic

28
Playwright E2E specs

Across 6 spec files, exercising the generation → review → export flow end to end

06 — REFLECTION

THE ARCHITECTURE IS THE TRUST MODEL

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.

07 — WHY THIS MATTERS

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.

NEXT STEP

LIKE WHAT YOU SEE? LET'S TALK ABOUT YOURS.