← The shelf
Case study 01 · Live

Recipe Intolerance Helper

A web app that checks a recipe against your food intolerances and flags the ingredients that clash. I designed and built it on my own, from the interface to the database, the photo scanning, the tests and the hosting.

Role
Solo: product, design, development, deployment
Stack
Next.js 16, FastAPI, PostgreSQL, Tesseract OCR
Hosting
Vercel, Render and Neon, all on free tiers

The app runs on free hosting, so the first visit after a quiet period can take up to a minute while the server wakes up.

Recipe Intolerance Helper: the start screen, a results screen with flagged and safe ingredients, and a photographed recipe card that the scan feature can read
Fig. 1 · The start screen, a result for a carbonara recipe, and the kind of printed recipe card the scan feature reads.
[ 01 ]

The problem

Anyone who avoids gluten, dairy, eggs or similar ingredients does the same chore for every recipe: read each ingredient line and decide whether it is a problem.

Two things make that slow. Ingredient names can hide the allergen, so you need to know what a word means before you can judge it. And recipes arrive in different forms: typed text, a web page, a screenshot, a printed card with two columns and decorative art. A simple keyword search handles none of that well.

[ 02 ]

Goals and constraints

The goal was a checker that anyone can use in seconds and that is honest about what it does not know.

  • Works without an account; logging in is only needed to save recipes and see history.
  • Accepts messy input: pasted text, a recipe fetched by name, or a photo.
  • Gives one of three clear answers per ingredient instead of guessing.

The constraints shaped the design as much as the goals did.

  • I was working alone with no budget, so the OCR engine and all hosting had to be free.
  • The free backend has 512 MB of memory, goes to sleep after 15 minutes without traffic, and takes about a minute to wake.
  • The scan feature is public, so it had to resist being overused.
[ 03 ]

The solution

You pick your intolerances, from a preset such as gluten-free or lactose-free or from a custom list of 14 tags, then bring in a recipe in one of three ways.

01Paste it. Type or paste the ingredient list.
02Look it up. Enter a dish name and the app fetches a recipe from TheMealDB.
03Scan it. Upload a photo of a printed recipe and OCR reads the ingredients.

Every ingredient comes back as one of three outcomes:

FlaggedIt matches one of your intolerances, with the reason shown
SafeIt is known and does not conflict
UnrecognizedThe app does not know this ingredient, so it says so instead of guessing

The third outcome is the one I care about most. A checker that quietly calls unknown ingredients “safe” is worse than no checker.

Logged-in users can save a checked recipe and look at their history. Photos are processed and never stored.

[ 04 ]

How it works

The app is three hosted parts and a database, plus two outside services.

Browserthe visitorVercelNext.js 16 · rewrites /apiRenderFastAPI · rate limiterTesseract OCR512 MB · sleeps after 15 minNeonPostgreSQLTheMealDBrecipe lookup/api
Fig. 2 · The path of a request. Dashed box: an outside service.

The browser never talks to Render directly. Vercel forwards /api calls to it, which is what keeps the login cookie first-party.

Every recipe, however it arrives, goes through the same four steps:

01ParseEach line is split into quantity, unit and ingredient name.
02NormalizeAccents, hyphens and plurals are cleaned up word by word, so “almonds” and “almond” compare equal.
03MatchThe name is compared with a dictionary of 619 ingredients: whole words only, longest match first, with a fixed tie-break so the result never changes between runs. Qualifiers such as “gluten-free” are understood.
04ReportEach line becomes flagged, safe or unrecognized.

A photo adds a step in front: OCR turns the picture into lines of text, picks out the ingredient column, and hands those lines to the same pipeline.

[ 05 ]

Challenges and decisions

Login across two hosting domains

The frontend lives on Vercel and the API on Render, so the login cookie became a third-party cookie, which browsers increasingly block. Instead of loosening cookie settings, I made the browser talk to one origin only: a Next.js rewrite forwards every /api request to the backend, so the cookie is first-party.

Reading messy recipe photos

Real recipe cards have two columns, decorative art and odd spacing, and plain OCR returns them as one jumbled stream. I use Tesseract's word-level output with a layout mode suited to columns, find column breaks from the gaps between words, and score each column for how much it looks like an ingredient list.

In the browser, large phone photos are shrunk first (over 2 MB, at most 5 million pixels, longest side 3000, JPEG at 0.85) with the camera's orientation respected, so uploads are fast and the server stays within its memory.

Free-tier cold starts

A sleeping server makes the first request fail or hang. The frontend retries up to 20 times, 4 seconds apart, and after 3 seconds shows a banner saying the server is waking up, so nobody thinks the app is broken. A scheduled ping every 10 minutes keeps the server awake in normal use.

Keeping a public scan endpoint fair

OCR is the heaviest thing the server does. I wrote a small sliding-window rate limiter: 10 scans per hour per visitor and 150 per hour across everyone, with tighter limits on login and registration. Refused requests get a 429 with a Retry-After header. The limiter takes an injected clock, which made it possible to test without waiting.

A parser bug found while preparing for interviews

While reviewing the code I found that a line like 1/0 cup sugar crashed the parser with a division error, and one bad line failed the whole recipe. The fix treats a line that only looks like it starts with a quantity as plain text, and new regression tests check that the other lines in the recipe still parse.

[ 06 ]

Quality and security

The backend has 165 automated pytest tests across 9 files, which run in CI on every push. Tests use an in-memory SQLite database, so they are fast and need no setup.

Normalizer36
Seed data checks36
Parser31
Matcher21
Rate limiting15
Scan endpoint11
Other (seed, OCR, check endpoint)15

On security, secrets come only from environment variables and the app refuses to start if one is missing. Cookies are marked Secure in production, cross-origin access is limited to the frontend's own address, the heavy endpoints are rate limited, and uploaded photos are never written to disk or the database.

[ 07 ]

Limitations and next steps

The app is a portfolio project, not a medical tool, and it has known gaps that I would rather state than hide.

  • Free hosting: the first visit after a quiet period is slow. A paid always-on host is on the roadmap.
  • Rate limiter: it keeps its counts in memory, so they reset when the server restarts and would not be shared across several servers.
  • Dictionary coverage: matching depends on my ingredient list, and some phrases are misread. For example, “stock cube” matches the “stock” entry and can be wrongly called safe.
  • Error handling: the check, lookup, scan and save calls handle network errors, but the navigation bar and the login, register and history pages do not yet.
  • Cleanup: the Pydantic config style is deprecated and one schema class is duplicated.
[ 08 ]

What I learned

The edge cases are the workThe happy path took a fraction of the time. Odd input, such as a bad fraction, a two-column photo or a sleeping server, is where the real design decisions were.
Say “I don't know”Adding the Unrecognized outcome made the product more trustworthy than a confident wrong answer would have.
Constraints are design inputFree tiers forced me to shrink images in the browser, limit scans, and design the waiting experience, and the app is better for all three.
Make time testableInjecting a clock into the rate limiter turned an hour-long behavior into a millisecond test.