DEV Community

Cover image for Snap a Dish, Get a Recipe: Building GraceSoft DishLens Solo
GraceSoft
GraceSoft

Posted on

Snap a Dish, Get a Recipe: Building GraceSoft DishLens Solo

This week the recording of GraceSoft CMS's "vibe coding" series is on pause. Something more exciting pulled my attention: my first venture into mobile apps, via React Native.

The pitch is simple. Snap a photo of a dish, and get back a recipe you could actually cook at home, plus its nutritional breakdown. Building it has been anything but simple — but that's the part worth writing about.

I'm building both the API and the mobile app myself. This is a new domain for me, still a one-woman show, and I leaned on Claude Code to move fast. But moving fast isn't the hard part of a project like this — knowing which edge cases matter, what to log, and how to structure a pipeline so it doesn't quietly drift is. That's domain knowledge and dev instinct, and no tool hands that to you.

The shape of the system

Right now everything lives in one monorepo (pnpm + Turborepo), with two apps and a handful of shared packages (shared-db for Prisma/Postgres, shared-config for env validation, shared-logger for structured logging).

DriveSync is the backend service that keeps a Pinecone vector database in sync with recipes stored as Google Docs in a Drive folder.

DishLens is the API that takes a single-dish photo and returns a "homable" recipe and its nutrition info. It's paired with a React Native (Expo) app that's the actual user-facing product.

DriveSync is done. DishLens is still in bug-fixing mode.

DriveSync: keeping a vector DB honest

The job sounds simple — watch a Drive folder, sync new or changed docs into Pinecone. The details are where it got interesting:

  • Change detection against Google Drive, so I'm not re-processing documents that haven't changed.
  • Extraction pipeline that pulls text out of Docs, Sheets, Slides, and PDFs — each with different structure and failure modes.
  • Token-budgeted chunking with overlap, so embeddings stay within model limits without cutting a recipe's context in half.
  • Batched embedding generation via OpenAI, with retries, because batch jobs against a third-party API will fail partway through eventually — plan for it up front.
  • Dedup and stale-vector deletion, so a recipe that's edited or removed from Drive doesn't leave orphaned, outdated vectors haunting search results.
  • Postgres-backed sync state, plus job locking, so overlapping sync runs don't stomp on each other.
  • Observability: sync logs, failure alerts, and status/audit endpoints — because a background sync job that fails silently is worse than one that doesn't exist.

DishLens: from photo to recipe

This is where most of the genuinely hard problems live, because the input is a photo taken by a real person on a real phone — not a clean API payload.

The image pipeline, before anything else happens:

  • File-type and size validation, and pixel-dimension limits, to reject junk before it costs an API call.
  • EXIF-safe re-encoding that strips GPS data and normalizes orientation — a privacy requirement as much as a correctness one.
  • Laplacian-variance blur detection to reject blurry photos before they hit the vision model. This one mattered more than I expected: a blurry photo doesn't just produce a bad recipe, it produces a confidently wrong one, and rejecting it early is both cheaper and a better user experience than letting the model guess.

Dish detection and generation:

  • Google Vision handles dish classification, with edge-case handling for things like multi-dish photos (a full table spread instead of one dish) and moderation to filter inappropriate content.
  • Recipe generation runs through Anthropic Claude.
  • Nutrition data comes from Edamam, matched back to the generated recipe — so the numbers aren't just a model's guess, they're grounded in a real nutrition database.
  • Photos themselves are normalized, uploaded, and served from GCS with signed URLs.

Everything else an API needs to not fall over:

  • JWT-based auth shared across services, with per-user rate limiting on the upload endpoint.
  • A Redis-backed session store, scoped per user with TTLs.
  • Chat that can draw on a user's own synced recipes, not just what Claude generates fresh — and chat sessions that work even when no dish has been scanned, for general recipe/nutrition Q&A.
  • The usual account plumbing: password change, account deletion, GET /auth/me.

The React Native app

Built in Expo, developed in structured milestones (M1 through M7, across five "rounds") — brand foundations and auth screens first, then the scan flow, then chat and meal planning, then a full dark mode pass with semantic theme tokens and accessibility work.

A few things that ate more time than expected:

  • "Unsupported FormDataPart implementation" — a fun way to discover that multipart uploads behave differently across platforms and Expo versions.
  • Compressing and resizing photos client-side before upload, both for speed and to keep the backend's size limits happy.
  • Proactively refreshing access tokens instead of waiting for a request to fail and refreshing reactively — small change, noticeably better UX.
  • Wiring auth state through SecureStore so sessions survive app restarts without storing tokens somewhere they shouldn't be.

The unglamorous part: deployment

If the commit history is honest about anything, it's that deployment ate real hours: Railway build errors, Prisma's engine binaries needing OpenSSL inside Docker, and Turbo v2's strict envMode silently stripping environment variables it didn't recognize. None of this is interesting to write about, and all of it is the kind of thing that quietly determines whether a project ships.

Where Claude Code actually helped

Claude Code was genuinely useful for moving quickly through boilerplate and unfamiliar territory — this is my first time in React Native and Expo. But the bottleneck on a project like this was never typing speed. It was deciding that a blurry photo needs its own rejection path before the vision model ever sees it, that stale vectors need active deletion instead of just letting Pinecone grow, that a background sync job needs an audit endpoint because "it probably worked" isn't good enough. Those are judgment calls that come from domain knowledge and experience, not from a tool.

What's next

DriveSync and DishLens currently share a monorepo. That's changing:

  • GraceSoft DriveSync and GraceSoft DishLens API are moving into separate repos.
  • DriveSync is headed toward open source, once I clean it up a bit more.
  • DishLens API stays proprietary — it'll power the GraceSoft DishLens app going forward.

DishLens (the API) is still in bug-fixing mode, and the app is catching up to it feature by feature. More to come as both stabilize.


Still a one-woman show at GraceSoft. If you've built something similar — recipe generation, vector sync pipelines, or your first React Native app — I'd love to hear how you tackled it.

Top comments (0)