Recipe Lookup with TheMealDB

Food Tracker (React + Express)

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.

Extending the Schema: A Recipe Cache

The same caching pattern Chapter 3 established for barcode lookups, applied to per-ingredient recipe results:

-- schema.sql (appended) CREATE TABLE IF NOT EXISTS recipe_cache ( ingredient TEXT PRIMARY KEY, meals TEXT NOT NULL, -- JSON array, stored as text cached_at TEXT NOT NULL DEFAULT (datetime('now')) );

The Suggestion Route

// routes/recipes.js import { Router } from "express"; import db from "../db.js"; const router = Router(); async function lookupIngredient(ingredient) { const key = ingredient.toLowerCase().trim().replace(/\s+/g, "_"); const cached = db.prepare("SELECT meals FROM recipe_cache WHERE ingredient = ?").get(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 || []; db.prepare( "INSERT OR REPLACE INTO recipe_cache (ingredient, meals, cached_at) VALUES (?, ?, datetime('now'))" ).run(key, JSON.stringify(meals)); return meals; } router.get("/suggest", async (req, res) => { const expiring = db.prepare(` SELECT name FROM items WHERE status = 'active' AND expiry_date IS NOT NULL AND expiry_date <= date('now', '+3 days') `).all(); 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;

A meal matching three expiring ingredients ranks above one matching only one — the same relevance-by-match-count sorting every course in this quartet uses for its own recipe feature.

A real, named advantage of this course's own architecture
Food Tracker (Django) named its own equivalent fan-out honestly as sequential — one ingredient's TheMealDB call waiting for the previous one to finish, a real, admitted performance cost. Here, Promise.all fires every ingredient's lookup concurrently instead of one after another, because Node's own async model makes that the natural way to write it, not a special optimization bolted on afterward. Five expiring ingredients means five requests in flight at once here, rather than five requests run one after the other — a genuine payoff of Chapter 1's own "one language, both ends" framing, where JavaScript's async-first design turns out to matter for more than just tooling convenience.
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

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 barcode_cache.

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 a generic pantry item name like "Trader Joe's Organic Chicken Thighs" might fail to match anything in TheMealDB even after normalization, and why this is described as an honest scope limit rather than a bug to fix.

📄 View solution
Exercise 3

Explain why recipe_cache is keyed by ingredient rather than by the full combination of expiring items on any given day, and what benefit that specific choice provides.

📄 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 table: recipe_cache, keyed by ingredient, storing the JSON meal list as text
  • Real advantage: Promise.all fires every ingredient lookup concurrently — a genuine payoff of Node's async model, vs. Food Tracker (Django)'s own honestly-named sequential fan-out
  • Real gotcha: 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