<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Ryan Christian</title>
    <description>The latest articles on DEV Community by Ryan Christian (@rkchristian).</description>
    <link>https://dev.to/rkchristian</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3951310%2F7b6edb3f-2e60-49ec-9658-804eeda623fa.jpeg</url>
      <title>DEV Community: Ryan Christian</title>
      <link>https://dev.to/rkchristian</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/rkchristian"/>
    <language>en</language>
    <item>
      <title>How I Built a Smart Recipe Finder with Vanilla JavaScript and a Free API</title>
      <dc:creator>Ryan Christian</dc:creator>
      <pubDate>Mon, 25 May 2026 23:31:31 +0000</pubDate>
      <link>https://dev.to/rkchristian/how-i-built-a-smart-recipe-finder-with-vanilla-javascript-and-a-free-api-37ln</link>
      <guid>https://dev.to/rkchristian/how-i-built-a-smart-recipe-finder-with-vanilla-javascript-and-a-free-api-37ln</guid>
      <description>&lt;p&gt;I built Pantry Roulette because I was broke and tired of staring into my fridge with no idea what to cook.&lt;br&gt;
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.&lt;br&gt;
The result is &lt;a href="https://pantryroulette.com" rel="noopener noreferrer"&gt;Pantry Roulette&lt;/a&gt; — a free, constraint-based recipe finder built entirely with vanilla HTML, CSS, and JavaScript. No frameworks, no build tools. Just the platform.&lt;br&gt;
Here's how the most interesting parts work.&lt;/p&gt;
&lt;h2&gt;
  
  
  The Data Problem: Designing the Recipe Object First
&lt;/h2&gt;

&lt;p&gt;Before writing a single line of UI code, I designed the data structure. Every recipe in the local database follows this shape:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{
  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."
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Scoring Engine: From Binary Filter to Ranked Results
&lt;/h2&gt;

&lt;p&gt;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.&lt;br&gt;
The fix was replacing the filter with a scoring system:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;const scored = normalized
  .map(recipe =&amp;gt; {
    const matched = recipe.ingredients.filter(ingredient =&amp;gt;
      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 =&amp;gt; recipe.score &amp;gt; 0)
  .sort((a, b) =&amp;gt; b.score - a.score);
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Vibe Engine: Inferring Mood from Recipe Data
&lt;/h2&gt;

&lt;p&gt;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.&lt;br&gt;
The problem is that &lt;strong&gt;TheMealDB API&lt;/strong&gt; doesn't have vibes, so I built an inference engine in &lt;code&gt;vibes.js&lt;/code&gt; that scores recipes against vibe profiles using three signals:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;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
};
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;br&gt;
This is a simplified version of how real recommendation engines work: defining feature signals and weighting them against a target.&lt;/p&gt;
&lt;h2&gt;
  
  
  Multi-Ingredient API Search with Promise.all()
&lt;/h2&gt;

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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;async function fetchRecipesByIngredients(ingredients) {
  const queries = ingredients.map(ingredient =&amp;gt;
    fetchRecipesByIngredient(ingredient)
  );
  const results = await Promise.all(queries);

  const merged = results.flatMap(meals =&amp;gt; meals.slice(0, 10));

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

  return deduplicated;
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Promise.all()&lt;/code&gt; 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.&lt;br&gt;
&lt;code&gt;Set&lt;/code&gt; 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.&lt;/p&gt;
&lt;h2&gt;
  
  
  The Diversity Filter: Never Show the Same Recipe Twice
&lt;/h2&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;function addSeenRecipe(recipeName) {
  const seen = getSeenRecipes();
  if (seen.includes(recipeName)) return;
  seen.push(recipeName);
  if (seen.length &amp;gt; 20) seen.shift();
  localStorage.setItem("pantryRouletteSeenRecipes", JSON.stringify(seen));
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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 &lt;code&gt;shift()&lt;/code&gt; to remove the oldest entry when the cap is hit — a simple FIFO queue implemented with a plain array.&lt;br&gt;
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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Accessibility: Building for Everyone
&lt;/h2&gt;

&lt;p&gt;Accessibility was a first class concern throughout the build, not an afterthought. A few things worth highlighting:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;ARIA live regions&lt;/strong&gt; announce dynamic content to screen readers without requiring the user to navigate to it:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;lt;div
  id="result"
  aria-live="polite"
  aria-atomic="true"
  role="region"
  aria-label="Recipe result"
&amp;gt;&amp;lt;/div&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Focus trapping&lt;/strong&gt; in the recipe modal keeps keyboard users from tabbing outside the modal while it's open:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;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();
      }
    }
  });
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Emoji in CSS&lt;/strong&gt; content rather than HTML means screen readers never encounter them:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;.meta-time::before { content: "⏱ "; }
.meta-ingredients::before { content: "🧄 "; }
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The app was tested with NVDA and Windows Narrator and passes WCAG AA colour contrast requirements.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Learned
&lt;/h2&gt;

&lt;p&gt;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.&lt;br&gt;
Here's what one real project taught me that months of tutorials struggled to convey:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Designing data structures before writing UI&lt;/strong&gt; — the shape of your data drives everything else&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Async JavaScript in a real context&lt;/strong&gt; — &lt;code&gt;Promise.all()&lt;/code&gt;, error handling, and loading states that actually matter&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Debugging without a Stack Overflow answer waiting&lt;/strong&gt; — learning to read error messages, use dev tools, and isolate problems systematically&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Accessibility as a practice&lt;/strong&gt; — ARIA, focus management, and screen reader testing are skills, not checkboxes&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Shipping&lt;/strong&gt; — DNS configuration, HTTPS, custom domains, Open Graph tags, and Google Search Console are all part of building something real&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The app is live at &lt;a href="https://pantryroulette.com" rel="noopener noreferrer"&gt;pantryroulette.com&lt;/a&gt; and the code is open source on &lt;a href="https://github.com/Ryan-carrot/pantry-roulette" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&lt;br&gt;
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.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Built with vanilla HTML, CSS, and JavaScript. Powered by TheMealDB API.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>beginners</category>
      <category>programming</category>
    </item>
  </channel>
</rss>
