Projects/Saucer
Saucer app icon

Case study · Cloud · AI

Saucer: An online/offline cloud-synced recipe app

I wanted to learn the full stack end to end, so I built a recipe app. It runs in the browser or as a native desktop app, and syncs through an Express + PostgreSQL server that I host myself. The recipes themselves are simple, the challenge was keeping the app working with no connection and still syncing cleanly across devices. I also used Claude Haiku to turn photos and loose text into structured recipes.

Role
Solo developer
Client
React 19 · TypeScript · Tauri 2
Server
Express 5 · PostgreSQL 18
Infra
Docker · nginx · Cognito · Cloudflare
4 import sources: URL, photo, text, manual
3 desktop OS installers built by CI on every push to main
~10 s for devices to converge after reconnecting

The system, layer by layer

Every layer sits behind an interface, so the UI, sync engine, and search index don't know or care where data physically lives.

Client
React 19 + TypeScript + Vite, state in Zustand
Ships as a web app on Cloudflare and as a Tauri 2 desktop app. Only 3 Rust IPC commands; all business logic stays in TypeScript.
Local store
RecipeStore interface
IndexedDB in the browser and plain Markdown files on desktop, chosen automatically at startup.
Search
sql.js (SQLite in WASM)
Full-text and tag search runs entirely in-process, with no server round trip.
Edge
nginx · Let's Encrypt
TLS termination in front of the API, running alongside it in Docker Compose.
API
Express 5
/sync/push, /sync/changes, /bootstrap, /extract-*, and /taxonomy.
Data
PostgreSQL 18
Recipes, mutations, taxonomy, and revisions. A file-backed store stands in during local development.
Auth
AWS Cognito · OIDC
The server verifies JWTs with aws-jwt-verify. Sync is optional; signed-out use stays local.
AI
Claude Haiku
Called only from the server, so API keys never reach the client.

Offline-first sync

Every local write lands in storage immediately and is queued as a named mutation (upsertRecipe, deleteRecipe). Clients push the queue when they're online and poll /sync/changes?cursor=… every 10 seconds and on window focus. The server deduplicates by clientMutationId, so a client that reconnects and replays its whole queue never creates duplicates.

Step through what happens when two devices edit the same recipe while both are offline:

Fig. 1: concurrent offline editstep 1 / 4 · illustrative values
Device AOffline
title
Pasta
rating
4 (changed this step)
revision
4
queue
upsert #a1
Device BOffline
title
Pasta 2.0 (changed this step)
rating
3
revision
4
queue
upsert #b1
ServerOnline
title
Pasta
rating
3
revision
4
queue
—

Both devices go offline and edit "Pasta". A changes the rating; B renames it. Each write is saved locally and queued.

A second guard covers deletes: a synced recipe can't be deleted while its device is offline, so an offline device can't remove a recipe other devices may have changed in the meantime.

Four ways to add a recipe

Every path ends in the same draft editor. The three automated paths send content to Claude Haiku on the server and can pull several recipes out of one source. Website imports fall back to the page's own structured data if the model call fails.

Paste URLclient
Fetch pageCloudflare Worker · Rust IPC on desktop
Claude Haikupage text → one or more recipes
Draft recipesreview, edit, save
FallbackIf the model call fails, an ld+json parser reads the structured recipe data most sites already publish.

On desktop, the Tauri shell fetches the page itself through the fetch_recipe_page IPC command.

Testing and shipping

This is the part that maps most directly to my day job. Every push to main runs the full check gate before any installer is built.

  • biome lint · formatLint and formatting enforced across the repo.
  • tsc --noEmitStrict type check on both the client and the server.
  • vitest + Testing LibraryClient tests covering the API client, auth boundaries, extraction, persistence, the editor store, and taxonomy sync.
  • vitest + supertestServer tests against the Express app and the store layer.
  • GitHub ActionsRuns npm run check, then builds and publishes Tauri installers for macOS, Windows, and Linux on every push to main.

Trade-offs I chose on purpose

  • Last write wins, not CRDTs. Two people on one account editing the same recipe at the same moment is rare, so ordered upserts plus a delete guard give convergence without the complexity of lossless merging.
  • Polling, not websockets. A 10-second cursor poll is simple to reason about, survives flaky connections, and is plenty for a recipe app.
  • Self-hosted server, managed auth. I run Postgres and the API myself to learn them, but leave identity to Cognito rather than rolling my own.