DEV Community

Cover image for How I Built a Smart Recipe Finder with Vanilla JavaScript and a Free API
Ryan Christian
Ryan Christian

Posted on

How I Built a Smart Recipe Finder with Vanilla JavaScript and a Free API

I built Pantry Roulette because I was broke and tired of staring into my fridge with no idea what to cook.
Every recipe app I tried assumed a perfectly stocked pantry. I had eggs, half an onion, and some pasta. So I built something that works the other way around — tell it what you actually have, pick a vibe, and it finds the best recipe it can from your real world constraints.
The result is Pantry Roulette — a free, constraint-based recipe finder built entirely with vanilla HTML, CSS, and JavaScript. No frameworks, no build tools. Just the platform.
Here's how the most interesting parts work.

The Data Problem: Designing the Recipe Object First

Before writing a single line of UI code, I designed the data structure. Every recipe in the local database follows this shape:

{
  name: "Pasta Aglio e Olio",
  ingredients: ["pasta", "garlic", "olive oil", "parsley"],
  vibe: "impress",
  time: 20,
  description: "Looks like you know what you're doing. You do now."
}
Enter fullscreen mode Exit fullscreen mode

Getting this right early meant the filtering and scoring logic had a consistent shape to work against. I learned the hard way, that changing your data structure halfway through a project is painful — so designing it first saves you from that.

The Scoring Engine: From Binary Filter to Ranked Results

The first version of the app used a binary filter — a recipe either matched or it didn't. That worked but felt dumb. If you had 3 out of 4 ingredients, you got nothing.
The fix was replacing the filter with a scoring system:

const scored = normalized
  .map(recipe => {
    const matched = recipe.ingredients.filter(ingredient =>
      userIngredients.includes(ingredient)
    );
    const ingredientScore = matched.length / recipe.ingredients.length;
    const vibeScore = scoreRecipeForVibe(recipe, selectedVibe);
    const combinedScore = (ingredientScore * 0.5) + (vibeScore * 0.5);

    return {
      ...recipe,
      score: combinedScore,
      matchedCount: matched.length
    };
  })
  .filter(recipe => recipe.score > 0)
  .sort((a, b) => b.score - a.score);
Enter fullscreen mode Exit fullscreen mode

Every recipe gets a score between 0 and 1 based on two signals — ingredient match percentage and vibe alignment — blended equally. The spread operator { ...recipe } copies the existing properties into a new object rather than mutating the original, which I learned is a habit worth building early.

The Vibe Engine: Inferring Mood from Recipe Data

The vibe selector is what makes Pantry Roulette different from a standard ingredient search. Users can pick from 10 vibes — Lazy, Comfort, Healthy, Impressive, Fancy, and more — and the app finds recipes that genuinely match that mood.
The problem is that TheMealDB API doesn't have vibes, so I built an inference engine in vibes.js that scores recipes against vibe profiles using three signals:

const vibeProfiles = {
  lazy: {
    keywords: ["mix", "stir", "microwave", "simple", "easy", "toss"],
    maxIngredients: 5,
    maxInstructionLength: 300,
    ingredientWeight: 0.4,
    keywordWeight: 0.4,
    lengthWeight: 0.2
  },
  impressive: {
    keywords: ["marinate", "reduce", "simmer", "layer", "carefully", "slowly"],
    minIngredients: 8,
    minInstructionLength: 500,
    ingredientWeight: 0.3,
    keywordWeight: 0.4,
    lengthWeight: 0.3
  }
  // ...8 more profiles
};
Enter fullscreen mode Exit fullscreen mode

Each profile defines keyword sets, ingredient count thresholds, and instruction length signals. A lazy recipe should have few ingredients and short instructions. An impressive recipe should have many ingredients and detailed steps. The scoring function checks all three signals and returns a weighted score.
This is a simplified version of how real recommendation engines work: defining feature signals and weighting them against a target.

Multi-Ingredient API Search with Promise.all()

Early versions only queried the API with the first ingredient the user entered. That meant typing "eggs, butter, pasta" only searched for egg dishes.
The fix was querying every ingredient simultaneously and merging the results:

async function fetchRecipesByIngredients(ingredients) {
  const queries = ingredients.map(ingredient =>
    fetchRecipesByIngredient(ingredient)
  );
  const results = await Promise.all(queries);

  const merged = results.flatMap(meals => meals.slice(0, 10));

  const seen = new Set();
  const deduplicated = merged.filter(meal => {
    if (seen.has(meal.idMeal)) return false;
    seen.add(meal.idMeal);
    return true;
  });

  return deduplicated;
}
Enter fullscreen mode Exit fullscreen mode

Promise.all() fires all the API requests simultaneously rather than sequentially — if you have three ingredients and each request takes 300ms, sequential would take 900ms. Parallel takes 300ms.
Set is the cleanest deduplication tool in JavaScript. It only stores unique values, so tracking seen meal IDs and filtering against them is a one liner.

The Diversity Filter: Never Show the Same Recipe Twice

Once the app was pulling from a live API, a new problem emerged — the same recipe kept winning because it scored highest for multiple vibes. The fix was a diversity filter backed by localStorage:

function addSeenRecipe(recipeName) {
  const seen = getSeenRecipes();
  if (seen.includes(recipeName)) return;
  seen.push(recipeName);
  if (seen.length > 20) seen.shift();
  localStorage.setItem("pantryRouletteSeenRecipes", JSON.stringify(seen));
}
Enter fullscreen mode Exit fullscreen mode

Previously seen recipes get a 0.3x score multiplier in the scoring pass, pushing them down the rankings without removing them entirely. The list is capped at 20 entries using shift() to remove the oldest entry when the cap is hit — a simple FIFO queue implemented with a plain array.
This is the same pattern used in real recommendation systems like Spotify's "don't play recently played songs" logic, just at a much smaller scale.

Accessibility: Building for Everyone

Accessibility was a first class concern throughout the build, not an afterthought. A few things worth highlighting:

  • ARIA live regions announce dynamic content to screen readers without requiring the user to navigate to it:
<div
  id="result"
  aria-live="polite"
  aria-atomic="true"
  role="region"
  aria-label="Recipe result"
></div>
Enter fullscreen mode Exit fullscreen mode
  • Focus trapping in the recipe modal keeps keyboard users from tabbing outside the modal while it's open:
function trapFocus(element) {
  const focusable = element.querySelectorAll(
    'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
  );
  const firstFocusable = focusable[0];
  const lastFocusable = focusable[focusable.length - 1];

  element.addEventListener("keydown", function(e) {
    if (e.key !== "Tab") return;
    if (e.shiftKey) {
      if (document.activeElement === firstFocusable) {
        e.preventDefault();
        lastFocusable.focus();
      }
    } else {
      if (document.activeElement === lastFocusable) {
        e.preventDefault();
        firstFocusable.focus();
      }
    }
  });
}
Enter fullscreen mode Exit fullscreen mode
  • Emoji in CSS content rather than HTML means screen readers never encounter them:
.meta-time::before { content: "⏱ "; }
.meta-ingredients::before { content: "🧄 "; }
Enter fullscreen mode Exit fullscreen mode

The app was tested with NVDA and Windows Narrator and passes WCAG AA colour contrast requirements.

What I Learned

I've been learning web development and hit the classic wall — tutorials made sense but I couldn't build anything real on my own. Pantry Roulette changed that.
Here's what one real project taught me that months of tutorials struggled to convey:

  • Designing data structures before writing UI — the shape of your data drives everything else

  • Async JavaScript in a real contextPromise.all(), error handling, and loading states that actually matter

  • Debugging without a Stack Overflow answer waiting — learning to read error messages, use dev tools, and isolate problems systematically

  • Accessibility as a practice — ARIA, focus management, and screen reader testing are skills, not checkboxes

  • Shipping — DNS configuration, HTTPS, custom domains, Open Graph tags, and Google Search Console are all part of building something real

The app is live at pantryroulette.com and the code is open source on GitHub.
If you're learning web development and stuck in tutorial hell, I recommend just building something you actually want to exist. The motivation to push through the hard parts comes from caring about what you're making.

Built with vanilla HTML, CSS, and JavaScript. Powered by TheMealDB API.

Top comments (0)