Case Study Consumer Health In Production

Corpus Mio: An Offline-First Nutrition PWA

TL;DR: A full-stack nutrition-tracking PWA that integrates the USDA food database, barcode lookup, and Claude-generated recipes — backed by a 16-table Postgres schema and an IndexedDB offline-sync engine that lets users log meals with no connection and reconciles automatically when they reconnect. The hard parts were offline correctness, cross-source nutrient normalization, and resolving AI-generated ingredients to real foods.
👤Role: Solo engineer (design, build, deploy)
📏Scope: Full-stack PWA + AI
⏱️Timeline: Personal project
🔧Environment: Next.js 16 · PostgreSQL · Ubuntu/Caddy

📸Snapshot

Business Objective

Give a user complete, private control over nutrition tracking — log food, macros, water, and weight against custom goals — with the reliability of a native app and no vendor lock-in.

Primary Technical Outcome

A production Next.js 16 application with a 16-table normalized schema, 33 API routes, an IndexedDB-based offline-first sync system with retry and deduplication, and Claude-powered recipe generation that resolves AI ingredients to real USDA foods.

My Role

Sole designer, engineer, and operator — from schema and offline architecture through deployment on Ubuntu behind Caddy with systemd.

Key Metrics

  • 16-table normalized PostgreSQL schema
  • 33 API route handlers across 11 groups
  • Offline-first sync (IndexedDB + Background Sync, 3-retry backoff)
  • USDA, Open Food Facts, and Claude integrations
Stakeholders
  • End user (privacy-focused, on-the-go logging)
  • Operator (single-server self-hosted deployment)

📋Context

Most nutrition apps force a choice — a powerful database that's painful to use, or a friendly counter with no depth — and nearly all assume a live connection and a cloud account. Corpus Mio set out to be both deep and private: authoritative USDA nutrition data, a home-pantry model that ties inventory to logging, AI recipe help, and full offline operation, all on infrastructure a single person owns. Logging happens in kitchens, gyms, and grocery aisles where connectivity is unreliable, so offline couldn't be an afterthought — it had to be the foundation.

⚠️Problem

Symptoms

  • Nutrition apps fail or stall without a connection, exactly when users log food
  • Database-first tools are accurate but tedious; simple trackers lack depth
  • Calorie logging is disconnected from the food actually in the user's home
  • Cloud-only apps mean handing personal health data to a third party

Root Causes

  • Online-only architectures treat the network as always available
  • Nutrition data lives in several incompatible source formats
  • Pantry and logging are modeled as separate concerns instead of one loop

Risk if Unresolved

  • Users abandon logging the moment the app fails offline
  • Inaccurate or tedious entry erodes trust in the numbers
  • Health data sits in a vendor's cloud outside the user's control

🔒Constraints & Requirements

Constraints

  • Single-developer build and ongoing maintenance
  • Self-hosted on one server — no managed cloud database
  • API keys (USDA, Anthropic) must never reach the client bundle
  • Offline correctness is a hard requirement, not a nice-to-have

Success Criteria

  • Meals, water, and weight can be logged fully offline and sync on reconnect
  • Nutrition data normalizes consistently across USDA, Open Food Facts, and custom foods
  • AI-generated recipes resolve to real foods with accurate nutrition
  • Secrets stay server-side; the app installs and runs as a PWA

Non-Goals

  • Social features (sharing, friends, feeds) — intentionally excluded for privacy
  • Multi-day meal planning (single-day logging by design for the MVP)
  • Native iOS/Android apps (PWA distribution instead)

🎯Strategy

Options Considered

  1. Online-only with server state: Treat the server as the source of truth and require connectivity to log.
    Pros: Simplest to build; no client-side sync engine. Cons: Fails in exactly the moments users log food; no native-app reliability.
    Why not chosen: Why not chosen — offline operation was a core requirement, not optional.
  2. localStorage for offline queue: Persist pending operations in localStorage.
    Pros: Trivial API; ubiquitous. Cons: Synchronous, ~5–10MB cap, no indexed queries or transactions.
    Why not chosen: Why not chosen — too limited for a durable, queryable operation queue.
  3. IndexedDB offline-first queue (chosen): Queue mutations in IndexedDB, sync via Background Sync with retry/backoff.
    Pros: Async, large capacity, indexed queries, transactional, survives restarts. Cons: More complex; requires dedup and conflict handling.
    Why chosen: Why chosen — the only option that makes offline a reliable foundation.

Decision Rationale

  • Offline-first via IndexedDB because logging happens where networks are weak, and a durable, queryable queue is required for correctness.
  • Next.js server components and route handlers so API keys (USDA, Anthropic) stay server-side and never enter the client bundle.
  • Drizzle ORM for explicit, visible SQL migrations and first-class TypeScript schema over hidden generation.
  • Claude (Sonnet) for recipe generation for reliable structured (JSON) output and strong reasoning over dietary constraints.

🚀Execution

Plan & Phases

  1. Schema & core logging: 16-table normalized Postgres schema; daily logs, entries, water, weight, and goals with constraints enforcing one-of food/meal/recipe per entry.
  2. Food data integration: USDA FoodData Central search with local caching, Open Food Facts barcode lookup, and a normalization layer unifying nutrient representations.
  3. Offline-first engine: IndexedDB operation queue, optimistic UI, Background Sync with 3-retry exponential backoff, and UUID-based deduplication.
  4. AI recipes: Claude generates recipes by meal type and diet (optionally pantry-only); generated ingredients are resolved to real USDA foods before the user reviews and saves.
  5. PWA & notifications: Installable PWA with tiered service-worker caching and timezone-aware Web Push reminders.

Rollout & Risk Controls

  • Environment validated at boot via a Zod schema — the process fails fast on misconfiguration
  • Invite-gated registration to control access during rollout
  • A health endpoint checks database connectivity for monitoring

🏗️Architecture

System Components

  • App & API (Next.js 16): server components plus 33 route handlers; secrets stay server-side.
  • Data layer (Drizzle + PostgreSQL): 16-table normalized schema with 19 explicit SQL migrations.
  • Offline engine (IndexedDB + service worker): durable operation queue, optimistic UI, Background Sync.
  • External integrations: USDA FoodData Central (search), Open Food Facts (barcode), Anthropic Claude (recipes).
  • Delivery (Caddy + systemd): TLS termination and process supervision on a single Ubuntu host.

Data Flows

  • Offline log: mutation queued in IndexedDB → optimistic UI update → Background Sync replays on reconnect → server confirms → operation cleared.
  • Food search: pantry → custom foods → cached USDA → live USDA API, caching new results locally on a miss.
  • AI recipe: Claude returns ingredients → fuzzy-match to cached foods → fall back to USDA search → fall back to a custom food → user reviews matches and saves with frozen per-serving nutrition.

🛡️Security

Threat Model
  • API key exposure: USDA and Anthropic keys could leak if used client-side.
  • Unauthorized access: another user's logs or health data must never be reachable.
  • Open registration abuse: a public health app invites spam accounts.

Controls Implemented

  • Server-only API routes keep USDA and Anthropic keys out of the client bundle
  • Session auth via httpOnly iron-session cookies; passwords hashed with bcrypt
  • Per-user authorization checks on all data endpoints; invite-gated registration
  • Boot-time environment validation (Zod) and per-IP rate limiting on auth routes

Verification

  • Zod request validation on API inputs, returning 400 on malformed bodies
  • Rate limiting on login/register, returning 429 with a retry hint
  • Account deletion cascades to all owned data (logs, meals, recipes, pantry)

⚙️Operations

Observability

  • A /api/health endpoint verifies database connectivity for monitoring
  • Structured request logging to stderr/syslog; no third-party analytics or tracking

Incident Response

  • Caddy + systemd restart-on-failure keeps the service available
  • Offline queue means transient backend outages don't lose user entries — they replay on recovery

Cost Controls

  • Local caching of USDA results and a pre-imported Foundation Foods set minimize paid API calls
  • Single-server, self-hosted deployment keeps fixed infrastructure cost low
  • Tiered service-worker caching reduces repeat network and origin load

📊Results

Secondary Outcomes
  • Pantry inventory auto-deducts when meals are logged, closing the home-kitchen loop
  • Timezone-aware Web Push reminders fire at the user's intended local time
  • CSV export gives users full ownership and portability of their data

💡Lessons Learned

What Worked

  • Offline-first via IndexedDB made the app feel native — entries are instant and survive flaky networks, restarts, and backend blips.
  • A central nutrient type plus adapter functions turned three incompatible data sources into one consistent representation, eliminating hand-rolled summation bugs.
  • Treating the AI's output as a draft to be resolved and reviewed — not trusted blindly — kept recipe nutrition accurate while still saving the user effort.

What I Would Do Differently

  • Add a small caching layer for AI recipe calls — they're currently un-cached and cost per generation.
  • Invest earlier in richer offline conflict resolution beyond last-write-wins for multi-device edits.
  • Plan unit conversions (cups/tablespoons ↔ grams) up front rather than constraining pantry quantities to grams and servings.

Playbook (Reusable Principles)

  • Make offline the foundation, not a feature — a durable, queryable queue (IndexedDB) plus optimistic UI and idempotent server writes.
  • Normalize external data behind one internal type with per-source adapters; never let third-party schemas leak into business logic.
  • Treat LLM output as a draft to validate and resolve against authoritative data, with a human review step before persistence.
  • Keep secrets server-side by design — server components and route handlers, never client-exposed keys.

📦Artifacts

Selected Technical Details


                // Offline-first: every mutation is queued, then replayed on reconnect.
export async function queueOperation(op: QueuedOperation) {
  op.tempId ??= crypto.randomUUID();   // dedupe key — survives retries
  await idb.put("pending-operations", { ...op, status: "pending", retryCount: 0 });
  applyOptimisticUpdate(op);            // UI updates immediately
  if (navigator.onLine) void flushQueue();
}

// On sync: 3 attempts with exponential backoff; stop on auth failure.
async function flushQueue() {
  for (const op of await idb.getAll("pending-operations")) {
    const res = await fetch(op.url, { method: op.method, body: JSON.stringify(op.payload) });
    if (res.ok) await idb.delete("pending-operations", op.tempId);
    else if (res.status === 401) break;            // re-auth required
    else await backoff(op);                          // 1s → 2s → 4s, max 3
  }
}