Recipe Lookup with TheMealDB

Food Tracker (React + Express + Prisma)

Chapter 9 · Recipe Lookup with TheMealDB

Chapter 6's alerts query already knows what's expiring soon. This chapter takes that same list and asks a second free API, TheMealDB, what could actually be cooked with it — reusing Chapter 3's own caching pattern, and Chapter 1's own concurrency story, largely unchanged.

Extending the Schema: A Recipe Cache

The same shape as Chapter 3's own BarcodeCache, keyed by ingredient this time:

// prisma/schema.prisma (appended) model RecipeCache { ingredient String @id meals String // JSON array, stored as text — see note below cachedAt DateTime @default(now()) }
Why meals is a plain String, not Prisma's own Json type
Prisma does have a dedicated Json field type — but checked directly against real, current Prisma issue history, its support for the SQLite provider specifically has been genuinely inconsistent across versions, with open bug reports even where partial support exists. Rather than depend on a still-uneven feature, this model stores the meal list the same way the sibling course does — as a plain string, serialized and parsed by hand — for portability and to avoid a dependency this course can't fully vouch for.

The Suggestion Route

// routes/recipes.js import { Router } from "express"; import prisma from "../db.js"; const router = Router(); async function lookupIngredient(ingredient) { const key = ingredient.toLowerCase().trim().replace(/\s+/g, "_"); const cached = await prisma.recipeCache.findUnique({ where: { ingredient: key } }); if (cached) return JSON.parse(cached.meals); const response = await fetch( `https://www.themealdb.com/api/json/v1/1/filter.php?i=${key}` ); const data = await response.json(); const meals = data.meals || []; await prisma.recipeCache.upsert({ where: { ingredient: key }, update: { meals: JSON.stringify(meals) }, create: { ingredient: key, meals: JSON.stringify(meals) } }); return meals; } router.get("/suggest", async (req, res) => { const threeDaysFromNow = new Date(Date.now() + 3 * 24 * 60 * 60 * 1000); const expiring = await prisma.item.findMany({ where: { status: "active", expiryDate: { not: null, lte: threeDaysFromNow } }, select: { name: true } }); const perIngredient = await Promise.all( expiring.map((item) => lookupIngredient(item.name)) ); const matchCounts = {}; perIngredient.flat().forEach((meal) => { if (!matchCounts[meal.idMeal]) { matchCounts[meal.idMeal] = { ...meal, matchCount: 0 }; } matchCounts[meal.idMeal].matchCount++; }); const sorted = Object.values(matchCounts).sort((a, b) => b.matchCount - a.matchCount); res.json(sorted.slice(0, 10)); }); export default router;

upsert() for the cache write is the exact same choice Chapter 3 made for BarcodeCache, and for the same reason — it safely handles two concurrent lookups of the same never-before-cached ingredient, where a plain create could collide on the ingredient primary key. select: { name: true } narrows the alerts-style query to just the one field this route actually needs, matching the sibling's own SELECT name FROM items — both courses already avoid pulling the whole row here.

A real advantage that has nothing to do with Prisma
Food Tracker (Django) named its own equivalent fan-out honestly as sequential — one ingredient's TheMealDB call waiting for the previous one to finish. Here, exactly as in the sibling course, Promise.all fires every ingredient's lookup concurrently, because Node's own async model makes that the natural way to write it. This win comes from Node and JavaScript, not from Prisma — it's identical in this course and its raw-SQL sibling, since both are Express apps running on the same runtime.
TheMealDB's ingredient names are exact-match, and pantry names rarely are
filter.php?i= expects TheMealDB's own specific ingredient vocabulary (chicken_breast, not chicken or chicken breasts) — a real, generic pantry item name like "Trader Joe's Organic Chicken Thighs" won't match cleanly no matter how it's normalized. The .toLowerCase().replace(/\s+/g, "_") normalization above handles simple cases; it does not solve the deeper problem of a free-text product name not lining up with a curated recipe database's own fixed vocabulary. This app's own honest scope stops at "best-effort matching," not guaranteed matches for every real product name.

The React Results Component

Unchanged from the sibling course — nothing here touches a date field or a naming convention that differs between the two courses:

function RecipeSuggestions() { const [recipes, setRecipes] = useState([]); useEffect(() => { fetch("/api/recipes/suggest") .then((r) => r.json()) .then(setRecipes); }, []); return ( <ul> {recipes.map((meal) => ( <li key={meal.idMeal}> <img src={meal.strMealThumb} alt={meal.strMeal} width="60" /> {meal.strMeal} — uses {meal.matchCount} expiring ingredient{meal.matchCount > 1 ? "s" : ""} </li> ))} </ul> ); }
The cache is per-ingredient, not per-suggestion
Caching keyed by ingredient rather than by the whole combination of expiring items means a cached "chicken" lookup gets reused the next time chicken appears in the alerts list, regardless of what else happened to be expiring alongside it that day — the same granular-caching principle as Chapter 3's own BarcodeCache.

Where This Course Is Headed

State management across the whole app next — where component-local state ends and a shared approach begins, now that scanning, alerts, history, and recipes all need to talk to each other.

Hands-On Exercises

Exercise 1

Explain the concrete difference in behavior between Promise.all(expiring.map(...)) and a for loop that awaits each lookupIngredient call one at a time, for five expiring ingredients.

📄 View solution
Exercise 2

Explain why RecipeCache stores meals as a plain String rather than Prisma's own Json field type, given what this chapter found checking Prisma's real issue history.

📄 View solution
Exercise 3

Explain why the cache write here uses upsert(), tracing the reasoning back to the specific chapter that first introduced this pattern and the specific problem it solves.

📄 View solution

Chapter 9 Quick Reference

  • Route: GET /api/recipes/suggest — fans out to TheMealDB per expiring ingredient, merges and sorts by match count
  • New model: RecipeCache, keyed by ingredient, meals stored as a JSON-encoded String (not Prisma's Json type — inconsistent SQLite support, checked directly)
  • Reused pattern: upsert() for the cache write, same as Chapter 3's BarcodeCache, same reason
  • Real advantage, unrelated to Prisma: Promise.all fires every ingredient lookup concurrently — a Node/JavaScript fact, identical in both this course and its raw-SQL sibling
  • Real gotcha, unchanged: TheMealDB expects its own exact ingredient vocabulary — generic product names often won't match cleanly even after normalization
  • Next chapter: State Management Across the App