GraceSoftGraceSoft

Snap a Dish, Get a Recipe: Building GraceSoft DishLens

Davina LeongAugust 5, 20266 min read

Recording of the GraceSoft CMS “vibe coding” series is on pause this week, because something more exciting pulled my attention: GraceSoft’s first venture into mobile apps, built with 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, and that’s the part worth writing about.

I’m building both the API and the mobile app myself. It’s a new domain for me, GraceSoft is 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. The hard part is knowing which edge cases matter, what to log, and how to structure a pipeline so it doesn’t quietly drift. That takes domain knowledge and dev instinct, and no tool hands those 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 and 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 photo of a single dish and returns a recipe you can make at home, plus its nutrition info. It’s paired with a React Native (Expo) app, which is the 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 and sync new or changed docs into Pinecone. The details are where it got interesting.

  • Change detection against Google Drive, so unchanged documents aren’t processed again.
  • An extraction pipeline that pulls text out of Docs, Sheets, Slides and PDFs, each with its own 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. A batch job against a third-party API will eventually fail partway through, so plan for it up front.
  • Dedup and deletion of stale vectors, so a recipe that’s edited or removed from Drive doesn’t leave outdated vectors haunting search results.
  • Sync state stored in Postgres, plus job locking, so overlapping sync runs don’t overwrite each other.
  • Observability: sync logs, failure alerts, and status and audit endpoints. 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 hard problems live. 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, file-size and pixel-dimension limits, to reject junk before it costs an API call.
  • Re-encoding that strips EXIF GPS data and normalises orientation. That’s as much a privacy requirement as a correctness one.
  • Blur detection using Laplacian variance, to reject blurry photos before they reach the vision model. This mattered more than I expected. A blurry photo doesn’t just produce a bad recipe, it produces a confidently wrong one. Rejecting it early is cheaper and gives a better user experience than letting the model guess.

Dish detection and generation:

  • Google Vision handles dish classification. It also covers edge cases like photos of several dishes (a full table spread instead of one dish) and moderation to filter out inappropriate content.
  • Recipe generation runs through Anthropic’s Claude.
  • Nutrition data comes from Edamam and is matched back to the generated recipe, so the numbers come from a real nutrition database rather than a model’s guess.
  • Photos are normalised, uploaded and served from GCS with signed URLs.

Everything else an API needs so it doesn’t fall over:

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

The React Native app

The app is built in Expo and developed in structured milestones (M1 through M7, across five “rounds”). Brand foundations and auth screens came 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 took 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 on the device before upload, both for speed and to stay within the backend’s size limits.
  • Refreshing access tokens before they expire instead of waiting for a request to fail. A small change with a noticeably better UX.
  • Keeping auth state in SecureStore, so sessions survive app restarts without tokens being stored somewhere they shouldn’t be.

The unglamorous part: deployment

If the commit history is honest about anything, it’s that deployment took real hours. There were Railway build errors, Prisma’s engine binaries needed OpenSSL inside Docker, and Turbo v2’s strict envMode silently stripped environment variables it didn’t recognise. None of this is interesting to write about, but all of it quietly decides whether a project ships.

Where Claude Code actually helped

Claude Code was useful for moving quickly through boilerplate and unfamiliar territory, since this is my first time in React Native and Expo. But typing speed was never the bottleneck. The real decisions were things like:

  • giving blurry photos their own rejection path before the vision model ever sees them
  • actively deleting stale vectors instead of letting Pinecone keep growing
  • giving a background sync job an audit endpoint, because “it probably worked” isn’t good enough

Those judgment calls 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 the GraceSoft DishLens API are moving into separate repos.
  • DriveSync is headed toward open source once I’ve cleaned it up a bit more.
  • The DishLens API stays proprietary and will power the GraceSoft DishLens app.

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


GraceSoft is still a one-woman show. If you’ve built something similar, whether that’s recipe generation, vector sync pipelines or your first React Native app, I’d love to hear how you tackled it.

Originally published on DEV.

Share this post

← Back to Blog