Corpus Mio: An Offline-First Nutrition PWA
Quick Links
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
- 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
- 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. - 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. - 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
- 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.
- Food data integration: USDA FoodData Central search with local caching, Open Food Facts barcode lookup, and a normalization layer unifying nutrient representations.
- Offline-first engine: IndexedDB operation queue, optimistic UI, Background Sync with 3-retry exponential backoff, and UUID-based deduplication.
- 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.
- 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
- 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
- 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
}
}