Recipe Lookup with TheMealDB

Food Tracker (React + Firebase)

Chapter 10 · Recipe Lookup with TheMealDB

This chapter delivers the last named feature from the very first chapter's own spec: recipes for what's about to expire. It reuses Chapter 3's own Cloud-Function-as-proxy pattern — and needs it for a genuinely new reason this time, not just the old one.

The Same Question, a New Answer

TheMealDB's basic filter-by-ingredient search is also free and keyless, so Chapter 3's own question applies again: does this need a Cloud Function at all? The caching/consistency/future-proofing reasons from Chapter 3 still apply — but there's a new, additional reason this time: this feature needs to search multiple expiring ingredients at once and combine the results into one meaningful list, and that kind of fan-out-and-merge step belongs server-side, not spread across several separate client-side fetch() calls the browser would otherwise have to coordinate itself.

TheMealDB's Real Limitation: One Ingredient at a Time

TheMealDB's filter endpoint (filter.php?i={ingredient}) only searches by a single ingredient per request — there's no "match any of these ingredients" query available on the free tier. Searching across everything expiring soon genuinely requires one request per ingredient, then merging the results afterward.

Writing the Cloud Function

const { onCall, HttpsError } = require("firebase-functions/v2/https"); exports.suggestRecipes = onCall(async (request) => { const ingredients = request.data.ingredients; // item names from Chapter 7's expiring-soon list if (!Array.isArray(ingredients) || ingredients.length === 0) { throw new HttpsError("invalid-argument", "ingredients array is required"); } const results = await Promise.all( ingredients.map(async (ingredient) => { const res = await fetch( `https://www.themealdb.com/api/json/v1/1/filter.php?i=${encodeURIComponent(ingredient)}` ); const data = await res.json(); return data.meals || []; }) ); // merge and deduplicate by meal ID, tracking which ingredient(s) matched each recipe const merged = new Map(); results.forEach((meals, i) => { meals.forEach((meal) => { if (!merged.has(meal.idMeal)) { merged.set(meal.idMeal, { ...meal, matchedIngredients: [] }); } merged.get(meal.idMeal).matchedIngredients.push(ingredients[i]); }); }); return Array.from(merged.values()); });

Wiring In Chapter 7's Own Data

The expiring-items list Chapter 7 already builds is exactly what feeds this function — no new query needed, just the item names passed straight through:

const suggestRecipes = httpsCallable(functions, "suggestRecipes"); async function loadRecipeIdeas(expiringItems) { const result = await suggestRecipes({ ingredients: expiringItems.map(item => item.name), }); return result.data; }

Sorting by Relevance

Since every merged recipe carries its own matchedIngredients array, sorting by matchedIngredients.length descending surfaces recipes that use the most expiring items first — directly serving the app's original point: using up as much of what's about to go to waste as possible in one meal, not just finding any recipe that happens to include one soon-to-expire ingredient.

The same pattern, a genuinely new reason
Chapter 3's Cloud Function existed for caching, consistency, and future-proofing — a single barcode always meant a single lookup. This chapter's Cloud Function needs those same reasons and a new one: server-side fan-out across multiple ingredient queries, merged into one coherent result before the client ever sees it. Recognizing when a familiar pattern still applies, and when it's being stretched to cover a genuinely new job, is worth noticing explicitly rather than assuming "we already built a Cloud Function for this kind of thing" covers every reason to build another one.
Cache recipe results the same way Chapter 3 cached barcodes
Common ingredients — chicken, eggs, milk — will be searched constantly across every user of this app. A recipeCache collection, keyed by ingredient name exactly like Chapter 3's own barcodeCache, avoids re-querying TheMealDB for the same ingredient over and over. Same technique, same reasoning, a second time.
Free, keyless APIs can still have real limits in practice
TheMealDB's free test key has no clearly documented rate limit, but a shared Cloud Function fanning out one request per expiring ingredient, multiplied across every user of this app, could realistically trigger undocumented throttling once usage grows — caching helps, but doesn't eliminate the risk entirely for ingredients that genuinely differ user to user. Worth monitoring for failures in practice, and worth knowing TheMealDB's own paid tier exists as a real option if this app ever had meaningfully many simultaneous users.

Where This Course Is Headed

Firebase Authentication (finally tightening the permissive rules left open since Chapter 6), deployment, and a capstone tying every chapter into one complete, working app.

Hands-On Exercises

Exercise 1

Explain why this Cloud Function fans out one request per ingredient rather than making a single combined call to TheMealDB, and name the specific API limitation that makes this necessary.

📄 View solution
Exercise 2

Explain the matchedIngredients.length sorting heuristic, and how it serves the original recipe-lookup feature's own intent described back in Chapter 1.

📄 View solution
Exercise 3

Explain why the recipeCache pattern from the tip box doesn't fully eliminate the rate-limit risk described in the warn-box, even though it helps.

📄 View solution

Chapter 10 Quick Reference

  • One ingredient per request — TheMealDB's real limitation, requiring a fan-out of parallel calls
  • suggestRecipes — a second Cloud Function, reusing Chapter 3's pattern for a new reason: server-side merge/dedupe
  • Chapter 7's data feeds this feature directly — the expiring-items list becomes the ingredients array
  • Sort by matchedIngredients.length — surfaces recipes using the most expiring items first
  • recipeCache — the same caching technique as barcodeCache, applied a second time
  • Next chapter: Firebase Authentication