Recipe Rabbit Hole
Chain multiple API calls to build a food explorer with AI suggestions.
Introduction
30 Second Summary
You know when you search for one recipe and suddenly find yourself deep in a food discovery spiral? One dish leads to a whole cuisine, which leads to a new ingredient, which leads to another dish entirely.
In this project, you will build a recipe exploration app that chains multiple API calls together. You will search for meals, view full recipe details, and click category or cuisine tags to discover related dishes.
What You'll Build
A single-page JavaScript web app that searches TheMealDB for recipes, displays detailed cooking information, and lets you explore related dishes through clickable tags.
By the end of this project, you'll have:
- A search interface that fetches recipes from TheMealDB and displays them as visual cards with thumbnails.
- A detail view showing full recipe information including ingredients, measurements, and cooking instructions.
- A rabbit hole exploration flow where clicking a meal's category or cuisine fetches and displays all related dishes.
- Secret Mission: An AI-powered "What Can I Cook?" feature using the Google Gemini API that suggests meals based on ingredients you have.
Are there any prerequisites?
This project assumes you have basic HTML, CSS, and JavaScript knowledge. You should be comfortable with fetch() and working with JSON data from a prior API project.
Set Up Your Project and Get Your API Key
Before you can start building the recipe search app, you need a place for your code to live and access to the APIs that power it.
In this step, you will create your project folder, grab a free Gemini API key, and verify that TheMealDB is responding to requests.
In this step, get ready to:
- Create your project files and open them in VS Code.
- Get a free Gemini API key from Google AI Studio.
- Test that TheMealDB API responds with recipe data.
Create your project folder and files
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal (macOS) or PowerShell (Windows) and press Enter to open it.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir recipe-rabbit-hole
cd recipe-rabbit-hole
- Create the three files your project needs by running this command:
macOS
touch index.html style.css app.js
Windows
New-Item index.html, style.css, app.js
- Confirm the files exist by running:
ls
You should see index.html, style.css, and app.js in the output.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type VS Code and press Enter to open it.
- In VS Code, click File in the top menu bar.
- Click Open Folder.
- Navigate to your Desktop and select the recipe-rabbit-hole folder, then click Open.
You should see all three files listed in the Explorer sidebar on the left.
Don't see the files in VS Code?
Make sure you selected the recipe-rabbit-hole folder itself, not a file inside it. The Explorer sidebar shows the contents of whichever folder you opened.
If you accidentally opened the wrong folder, click File then Open Folder again and pick the correct one.
help me find my recipe-rabbit-hole folder in VS Code
Get your free Gemini API key
Later in this project, you will add an AI feature powered by Google's Gemini API. To call the API, you need an API key that authenticates your requests.
- Open your browser and go to aistudio.google.com.
- Sign in with your Google account.
- Click Get API Key in the left sidebar.
- Click Create API key.
- Copy the key that appears and save it somewhere safe (a text file, a notes app, or a password manager).
What is an API key?
An API key is a unique string that identifies your app to a service. When you send a request to the Gemini API, you include this key so Google knows who is making the call.
The Gemini free tier gives you free input and output tokens, which is more than enough for this project. No credit card is required.
Keep your key private
Never share your API key publicly or commit it to a public repository. Treat it like a password.
Seeing a permission error?
If Google AI Studio shows a permission error when creating your key, try signing out and signing back in. You can also try opening the page in an incognito or private browser window.
help me troubleshoot my Gemini API key creation in Google AI Studio
Test that TheMealDB responds
TheMealDB is a free recipe database with a REST API that requires no signup. You will use it as the primary data source for searching and displaying recipes.
- Open a new browser tab and paste this URL into the address bar:
https://www.themealdb.com/api/json/v1/1/search.php?s=Arrabiata
You should see a page full of JSON data. Look for the meal name "Spicy Arrabiata Penne" near the top of the response.
What is TheMealDB?
TheMealDB is a free, open recipe database. Its API uses a simple key (the number 1) embedded in the URL path. No signup or authentication header is needed for development use.
This is different from the Gemini API, which requires a key in a request header. Working with both patterns teaches you how different APIs handle authentication.
Seeing an error or blank page?
Make sure you copied the full URL including the ?s=Arrabiata at the end. If you see a browser error, check that you have an internet connection and try refreshing.
help me get TheMealDB API working in my browser
Your workspace is ready and both APIs are accessible. Next up, you will build the search interface and display recipe cards from TheMealDB.
Build the Search and Display Recipe Cards
Your project files are ready and you have confirmed TheMealDB responds with real recipe data. Now it is time to build the core of your app: a search interface that fetches meals and displays them as visual cards.
In this step, you will write the HTML skeleton for your entire page, style it with CSS, and then write JavaScript functions that call TheMealDB and render the results. By the end, you will be able to search for any dish and see recipe cards appear with images.
In this step, get ready to:
- Build the full HTML structure with search, results, detail, and AI sections.
- Style the page with a card grid layout and hover effects.
- Write JavaScript functions that fetch meals from TheMealDB and display them as cards.
Set up the HTML structure and CSS
Your app needs a page structure that includes all the sections you will build across the next few steps. You will create everything now so that your JavaScript can reference these elements right away.
- In VS Code, open index.html and paste this full page structure:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Recipe Rabbit Hole</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<header>
<h1>Recipe Rabbit Hole</h1>
<p>Search for a dish and fall down the rabbit hole of flavors</p>
</header>
<main>
<section id="search-section">
<div class="search-bar">
<input type="text" id="search-input" placeholder="Search for a meal...">
<button id="search-btn">Search</button>
<button id="surprise-btn">Surprise Me</button>
</div>
</section>
<section id="filter-heading" class="hidden">
<h2 id="filter-title"></h2>
<button id="clear-filter-btn">Clear Filter</button>
</section>
<section id="results-section">
<div id="results-grid" class="card-grid"></div>
</section>
<section id="detail-section" class="hidden">
<button id="back-btn">Back to results</button>
<div id="meal-detail">
<img id="detail-img" src="" alt="">
<div id="detail-info">
<h2 id="detail-title"></h2>
<div id="detail-tags">
<span id="detail-category" class="tag clickable"></span>
<span id="detail-area" class="tag clickable"></span>
</div>
<h3>Ingredients</h3>
<ul id="detail-ingredients"></ul>
<h3>Instructions</h3>
<p id="detail-instructions"></p>
</div>
</div>
</section>
<section id="ai-section">
<h2>What Can I Cook?</h2>
<p>Enter ingredients you have and let AI suggest meals</p>
<div class="ai-input-bar">
<input type="text" id="ai-input" placeholder="e.g. chicken, rice, garlic">
<button id="ai-btn">Get Ideas</button>
</div>
<div id="ai-loading" class="hidden">Thinking...</div>
<div id="ai-results"></div>
</section>
</main>
<script src="app.js"></script>
</body>
</html>
What does this HTML do?
This page has five main sections, most of which start hidden:
- search-section holds the search bar and buttons that are always visible.
- filter-heading will display a heading when browsing by category or area (hidden for now).
- results-section is the container where recipe cards will appear.
- detail-section will show full recipe details when you click a card (hidden for now).
- ai-section is for the Gemini AI feature you will build in the Secret Mission.
- Save index.html.
- Right-click index.html in the VS Code Explorer sidebar and select Open with Live Server (or double-click the file to open it directly in your browser).
- You should see an orange header reading "Recipe Rabbit Hole" and a search bar with two buttons below it.
Don't see the orange header?
- Make sure you saved index.html before refreshing.
- Confirm the file is named exactly index.html (not index.html.txt or similar).
Why is my page blank or unstyled?
Now let's add the styling that will make your cards look polished.
- Open style.css and paste the following styles:
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background-color: #fafafa;
color: #333;
line-height: 1.6;
}
header {
text-align: center;
padding: 2rem 1rem;
background-color: #ff6b35;
color: white;
}
header h1 {
font-size: 2rem;
margin-bottom: 0.5rem;
}
main {
max-width: 1000px;
margin: 0 auto;
padding: 2rem 1rem;
}
.search-bar {
display: flex;
gap: 0.5rem;
margin-bottom: 2rem;
flex-wrap: wrap;
}
.search-bar input {
flex: 1;
padding: 0.75rem 1rem;
font-size: 1rem;
border: 2px solid #ddd;
border-radius: 8px;
min-width: 200px;
}
.search-bar input:focus {
outline: none;
border-color: #ff6b35;
}
button {
padding: 0.75rem 1.5rem;
font-size: 1rem;
border: none;
border-radius: 8px;
cursor: pointer;
font-weight: 600;
}
#search-btn {
background-color: #ff6b35;
color: white;
}
#surprise-btn {
background-color: #2ec4b6;
color: white;
}
#back-btn {
background-color: #666;
color: white;
margin-bottom: 1rem;
}
#clear-filter-btn {
background-color: #666;
color: white;
font-size: 0.875rem;
padding: 0.5rem 1rem;
}
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 1.5rem;
}
.meal-card {
background: white;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
cursor: pointer;
transition: transform 0.2s;
}
.meal-card:hover {
transform: translateY(-4px);
}
.meal-card img {
width: 100%;
height: 180px;
object-fit: cover;
}
.meal-card-info {
padding: 1rem;
}
.meal-card-info h3 {
font-size: 1rem;
margin-bottom: 0.5rem;
}
.meal-card-info .tag {
display: inline-block;
font-size: 0.75rem;
padding: 0.25rem 0.5rem;
border-radius: 4px;
margin-right: 0.25rem;
}
.tag {
background-color: #f0f0f0;
color: #555;
}
.tag.clickable {
cursor: pointer;
background-color: #e8f4f8;
color: #2ec4b6;
font-weight: 600;
}
.tag.clickable:hover {
background-color: #2ec4b6;
color: white;
}
.hidden {
display: none;
}
What do these styles do?
- The .card-grid uses CSS Grid to auto-fill columns that are at least 220px wide. Cards wrap to new rows as the screen shrinks.
- The .meal-card gets a subtle shadow and lifts up on hover via transform: translateY(-4px).
- The .hidden class uses display: none to toggle sections on and off from JavaScript.
- Save style.css and refresh your browser.
- The header should now be a vibrant orange bar. The search input and buttons should have rounded corners and clear styling.
Styles not applying?
- Check that your <link rel="stylesheet" href="style.css"> line is in the <head> of index.html.
- Make sure the file is named exactly style.css (not styles.css or style.CSS).
Help me debug why my CSS isn't loading.
Write the search and display functions
With the page structure in place, your JavaScript needs to fetch data from TheMealDB and turn the response into visual cards. You will start by setting up constants and DOM references, then build the search logic.
- Open app.js and paste the following constants and DOM references at the top:
const MEALDB_BASE = "https://www.themealdb.com/api/json/v1/1";
const GEMINI_API_KEY = "your-api-key-here";
const GEMINI_URL = "https://generativelanguage.googleapis.com/v1beta/models/gemini-3.5-flash:generateContent";
// Get references to all interactive elements on the page
const searchInput = document.getElementById("search-input");
const searchBtn = document.getElementById("search-btn");
const surpriseBtn = document.getElementById("surprise-btn");
const resultsGrid = document.getElementById("results-grid");
const resultsSection = document.getElementById("results-section");
const detailSection = document.getElementById("detail-section");
const backBtn = document.getElementById("back-btn");
const filterHeading = document.getElementById("filter-heading");
const filterTitle = document.getElementById("filter-title");
const clearFilterBtn = document.getElementById("clear-filter-btn");
const aiInput = document.getElementById("ai-input");
const aiBtn = document.getElementById("ai-btn");
const aiLoading = document.getElementById("ai-loading");
const aiResults = document.getElementById("ai-results");
// Store the last search results so we can return to them
let lastSearchResults = [];
What does this code do?
- MEALDB_BASE stores the base URL for all TheMealDB API calls. The free test key 1 is embedded directly in the path.
- GEMINI_API_KEY and GEMINI_URL are placeholders for the AI feature you will build later.
- Each document.getElementById call grabs a reference to an HTML element so you can manipulate it from JavaScript without searching the DOM repeatedly.
- lastSearchResults stores the most recent search so you can return to it after viewing a recipe detail.
Next, you need event listeners so the buttons actually do something when clicked.
- Add the following event listeners below your DOM references in app.js:
// Listen for search button clicks and Enter key presses
searchBtn.addEventListener("click", () => {
const query = searchInput.value.trim();
if (query) {
searchMeals(query);
}
});
searchInput.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
const query = searchInput.value.trim();
if (query) {
searchMeals(query);
}
}
});
// Wire up remaining buttons to their handler functions
surpriseBtn.addEventListener("click", getRandomMeal);
backBtn.addEventListener("click", showResults);
clearFilterBtn.addEventListener("click", clearFilter);
What do these listeners do?
- The search button and the Enter key both grab the text from the input, trim whitespace, and call searchMeals if the input is not empty.
- The remaining three listeners connect buttons to functions you will define below and in later steps. JavaScript will not throw an error for referencing an undefined function until the button is actually clicked.
Now let's write the function that actually calls TheMealDB when the user searches.
- Add the searchMeals function below your event listeners in app.js:
// Fetch meals matching the user's search query
async function searchMeals(query) {
const response = await fetch(`${MEALDB_BASE}/search.php?s=${query}`);
const data = await response.json();
filterHeading.classList.add("hidden");
displayMeals(data.meals);
}
What does searchMeals do?
This function builds the full API URL by appending the user's query to the search endpoint. It uses fetch with await to get the response, then parses the JSON body.
The data.meals property contains an array of meal objects (or null if nothing matched). It passes that array to displayMeals which you will write next.
The displayMeals function takes an array of meal objects and turns each one into a card on the page.
- Add the displayMeals and showResults functions below searchMeals in app.js:
// Create a card for each meal and add it to the results grid
function displayMeals(meals) {
resultsGrid.innerHTML = "";
if (!meals) {
resultsGrid.innerHTML = "<p>No meals found. Try a different search.</p>";
return;
}
lastSearchResults = meals;
meals.forEach((meal) => {
const card = document.createElement("div");
card.classList.add("meal-card");
card.innerHTML = `
<img src="${meal.strMealThumb}/medium" alt="${meal.strMeal}">
<div class="meal-card-info">
<h3>${meal.strMeal}</h3>
${meal.strCategory ? `<span class="tag">${meal.strCategory}</span>` : ""}
${meal.strArea ? `<span class="tag">${meal.strArea}</span>` : ""}
</div>
`;
card.addEventListener("click", () => showMealDetail(meal.idMeal));
resultsGrid.appendChild(card);
});
showResults();
}
// Toggle visibility so results are shown and detail is hidden
function showResults() {
detailSection.classList.add("hidden");
resultsSection.classList.remove("hidden");
}
What does displayMeals do?
- It first clears any previous results from the grid.
- If meals is null (no matches), it shows a friendly message and stops.
- For each meal, it creates a card element with the thumbnail (using the /medium size variant), the meal name, and optional category and area tags.
- Each card gets a click listener that will call showMealDetail (which you will write in the next step).
- showResults is a helper that hides the detail section and makes the results section visible.
- Save app.js and refresh your browser.
- Type chicken in the search box and click Search. You should see recipe cards appear with food images, meal names, and small tags.
No cards appearing after searching?
- Open DevTools with Cmd+Option+I (macOS) or Ctrl+Shift+I (Windows) and check the Console tab for errors.
- A common mistake is a typo in the element IDs. Double-check that IDs in your HTML (like results-grid) match exactly what getElementById references in your JavaScript.
- Make sure the <script src="app.js"></script> tag is at the bottom of <body>, not in <head>. Otherwise the DOM elements do not exist yet when your code runs.
Why aren't my recipe cards showing up after I click Search?
Wire up the Surprise Me button
TheMealDB has a random meal endpoint that returns a single surprise dish. Your event listener for the Surprise Me button is already registered. Now you just need the function it calls.
- Add the getRandomMeal function between searchMeals and displayMeals in app.js:
// Fetch a single random meal from TheMealDB
async function getRandomMeal() {
const response = await fetch(`${MEALDB_BASE}/random.php`);
const data = await response.json();
filterHeading.classList.add("hidden");
displayMeals(data.meals);
}
What does getRandomMeal do?
It calls the random.php endpoint which returns a single-item meals array containing one random recipe. Your existing displayMeals function handles it just like search results since both share the same response shape.
- Save app.js and refresh your browser.
- Click Surprise Me. A single random recipe card should appear with an image, name, and tags.
- Click it again a few times. Each click should show a different random meal.
Surprise Me not working?
- Check the Console for a "getRandomMeal is not defined" error. If you see this, make sure the function is declared with the function keyword (which hoists the declaration) and that there are no syntax errors above it.
- If you see a card with no image, check that your img src uses ${meal.strMealThumb}/medium with the correct template literal backtick syntax.
My Surprise Me button doesn't work. Help me debug.
✔️ Awesome, I've got everything!
Great. Double check you have saved all three files before moving on.
ⓧ I'd like to double check the full code
Here is the complete code for all three files at the end of this step.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Recipe Rabbit Hole</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<header>
<h1>Recipe Rabbit Hole</h1>
<p>Search for a dish and fall down the rabbit hole of flavors</p>
</header>
<main>
<section id="search-section">
<div class="search-bar">
<input type="text" id="search-input" placeholder="Search for a meal...">
<button id="search-btn">Search</button>
<button id="surprise-btn">Surprise Me</button>
</div>
</section>
<section id="filter-heading" class="hidden">
<h2 id="filter-title"></h2>
<button id="clear-filter-btn">Clear Filter</button>
</section>
<section id="results-section">
<div id="results-grid" class="card-grid"></div>
</section>
<section id="detail-section" class="hidden">
<button id="back-btn">Back to results</button>
<div id="meal-detail">
<img id="detail-img" src="" alt="">
<div id="detail-info">
<h2 id="detail-title"></h2>
<div id="detail-tags">
<span id="detail-category" class="tag clickable"></span>
<span id="detail-area" class="tag clickable"></span>
</div>
<h3>Ingredients</h3>
<ul id="detail-ingredients"></ul>
<h3>Instructions</h3>
<p id="detail-instructions"></p>
</div>
</div>
</section>
<section id="ai-section">
<h2>What Can I Cook?</h2>
<p>Enter ingredients you have and let AI suggest meals</p>
<div class="ai-input-bar">
<input type="text" id="ai-input" placeholder="e.g. chicken, rice, garlic">
<button id="ai-btn">Get Ideas</button>
</div>
<div id="ai-loading" class="hidden">Thinking...</div>
<div id="ai-results"></div>
</section>
</main>
<script src="app.js"></script>
</body>
</html>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background-color: #fafafa;
color: #333;
line-height: 1.6;
}
header {
text-align: center;
padding: 2rem 1rem;
background-color: #ff6b35;
color: white;
}
header h1 {
font-size: 2rem;
margin-bottom: 0.5rem;
}
main {
max-width: 1000px;
margin: 0 auto;
padding: 2rem 1rem;
}
.search-bar {
display: flex;
gap: 0.5rem;
margin-bottom: 2rem;
flex-wrap: wrap;
}
.search-bar input {
flex: 1;
padding: 0.75rem 1rem;
font-size: 1rem;
border: 2px solid #ddd;
border-radius: 8px;
min-width: 200px;
}
.search-bar input:focus {
outline: none;
border-color: #ff6b35;
}
button {
padding: 0.75rem 1.5rem;
font-size: 1rem;
border: none;
border-radius: 8px;
cursor: pointer;
font-weight: 600;
}
#search-btn {
background-color: #ff6b35;
color: white;
}
#surprise-btn {
background-color: #2ec4b6;
color: white;
}
#back-btn {
background-color: #666;
color: white;
margin-bottom: 1rem;
}
#clear-filter-btn {
background-color: #666;
color: white;
font-size: 0.875rem;
padding: 0.5rem 1rem;
}
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 1.5rem;
}
.meal-card {
background: white;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
cursor: pointer;
transition: transform 0.2s;
}
.meal-card:hover {
transform: translateY(-4px);
}
.meal-card img {
width: 100%;
height: 180px;
object-fit: cover;
}
.meal-card-info {
padding: 1rem;
}
.meal-card-info h3 {
font-size: 1rem;
margin-bottom: 0.5rem;
}
.meal-card-info .tag {
display: inline-block;
font-size: 0.75rem;
padding: 0.25rem 0.5rem;
border-radius: 4px;
margin-right: 0.25rem;
}
.tag {
background-color: #f0f0f0;
color: #555;
}
.tag.clickable {
cursor: pointer;
background-color: #e8f4f8;
color: #2ec4b6;
font-weight: 600;
}
.tag.clickable:hover {
background-color: #2ec4b6;
color: white;
}
.hidden {
display: none;
}
const MEALDB_BASE = "https://www.themealdb.com/api/json/v1/1";
const GEMINI_API_KEY = "your-api-key-here";
const GEMINI_URL = "https://generativelanguage.googleapis.com/v1beta/models/gemini-3.5-flash:generateContent";
const searchInput = document.getElementById("search-input");
const searchBtn = document.getElementById("search-btn");
const surpriseBtn = document.getElementById("surprise-btn");
const resultsGrid = document.getElementById("results-grid");
const resultsSection = document.getElementById("results-section");
const detailSection = document.getElementById("detail-section");
const backBtn = document.getElementById("back-btn");
const filterHeading = document.getElementById("filter-heading");
const filterTitle = document.getElementById("filter-title");
const clearFilterBtn = document.getElementById("clear-filter-btn");
const aiInput = document.getElementById("ai-input");
const aiBtn = document.getElementById("ai-btn");
const aiLoading = document.getElementById("ai-loading");
const aiResults = document.getElementById("ai-results");
let lastSearchResults = [];
searchBtn.addEventListener("click", () => {
const query = searchInput.value.trim();
if (query) {
searchMeals(query);
}
});
searchInput.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
const query = searchInput.value.trim();
if (query) {
searchMeals(query);
}
}
});
surpriseBtn.addEventListener("click", getRandomMeal);
backBtn.addEventListener("click", showResults);
clearFilterBtn.addEventListener("click", clearFilter);
async function searchMeals(query) {
const response = await fetch(`${MEALDB_BASE}/search.php?s=${query}`);
const data = await response.json();
filterHeading.classList.add("hidden");
displayMeals(data.meals);
}
async function getRandomMeal() {
const response = await fetch(`${MEALDB_BASE}/random.php`);
const data = await response.json();
filterHeading.classList.add("hidden");
displayMeals(data.meals);
}
function displayMeals(meals) {
resultsGrid.innerHTML = "";
if (!meals) {
resultsGrid.innerHTML = "<p>No meals found. Try a different search.</p>";
return;
}
lastSearchResults = meals;
meals.forEach((meal) => {
const card = document.createElement("div");
card.classList.add("meal-card");
card.innerHTML = `
<img src="${meal.strMealThumb}/medium" alt="${meal.strMeal}">
<div class="meal-card-info">
<h3>${meal.strMeal}</h3>
${meal.strCategory ? `<span class="tag">${meal.strCategory}</span>` : ""}
${meal.strArea ? `<span class="tag">${meal.strArea}</span>` : ""}
</div>
`;
card.addEventListener("click", () => showMealDetail(meal.idMeal));
resultsGrid.appendChild(card);
});
showResults();
}
function showResults() {
detailSection.classList.add("hidden");
resultsSection.classList.remove("hidden");
}
Your search interface is live and pulling real data from TheMealDB. Next up, you will build the detail view so clicking a card reveals the full recipe with ingredients, measurements, and cooking instructions.
Build the Recipe Detail View
Your recipe cards look great and your search is working. But right now, clicking a card does nothing visible because the function it calls doesn't exist yet.
In this step, you'll build the detail view that appears when someone clicks a recipe card. This view fetches the full recipe data from TheMealDB's lookup endpoint and displays the meal image, a list of ingredients with measurements, and cooking instructions.
In this step, get ready to:
- Style the detail view layout with CSS.
- Write the showMealDetail function to fetch and display full recipe info.
- Test clicking cards to navigate between the results grid and detail view.
Style the detail view layout
Your HTML already has a #detail-section with placeholders for the meal image, title, tags, ingredients, and instructions. It's hidden by default with the hidden class. Now you need CSS to arrange these elements into a readable layout when they become visible.
- In style.css, scroll to the bottom of the file (below the .hidden rule) and add the following styles:
#filter-heading {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1rem;
}
#meal-detail {
display: flex;
gap: 2rem;
flex-wrap: wrap;
}
#detail-img {
width: 300px;
height: 300px;
object-fit: cover;
border-radius: 12px;
}
#detail-info {
flex: 1;
min-width: 280px;
}
#detail-title {
font-size: 1.5rem;
margin-bottom: 0.5rem;
}
#detail-tags {
margin-bottom: 1rem;
}
#detail-ingredients {
margin-bottom: 1rem;
padding-left: 1.5rem;
}
#detail-ingredients li {
margin-bottom: 0.25rem;
}
#detail-instructions {
white-space: pre-line;
}
What does this CSS do?
- #meal-detail uses flexbox to place the meal image side by side with the recipe info. The flex-wrap: wrap ensures it stacks on narrow screens.
- #detail-img is a fixed 300x300 square with rounded corners and object-fit: cover to prevent stretching.
- #detail-instructions uses white-space: pre-line so the recipe's line breaks are preserved.
- Save style.css.
You won't see a visible change yet because the detail section is still hidden. The next chunk brings it to life.
Write the showMealDetail function
When a user clicks a recipe card, the app needs to fetch the full recipe from TheMealDB's lookup endpoint. This endpoint returns all 20 possible ingredient/measurement pairs, even though most meals only use 8-12 of them.
Why 20 ingredient fields?
TheMealDB stores ingredients as strIngredient1 through strIngredient20 (and matching strMeasure1 through strMeasure20). Unused slots are empty strings or null.
Your function will loop through all 20 pairs and only display the ones that contain actual ingredient text.
- In app.js, find the showResults function. Add the following showMealDetail function directly above it:
async function showMealDetail(id) {
// Fetch full meal details by ID from the lookup endpoint
const response = await fetch(`${MEALDB_BASE}/lookup.php?i=${id}`);
const data = await response.json();
const meal = data.meals[0];
// Populate the detail view with meal image and title
document.getElementById("detail-img").src = meal.strMealThumb;
document.getElementById("detail-img").alt = meal.strMeal;
document.getElementById("detail-title").textContent = meal.strMeal;
// Set up the category and area tags as clickable elements
const categoryTag = document.getElementById("detail-category");
categoryTag.textContent = meal.strCategory;
categoryTag.onclick = () => filterByCategory(meal.strCategory);
const areaTag = document.getElementById("detail-area");
areaTag.textContent = meal.strArea;
areaTag.onclick = () => filterByArea(meal.strArea);
// Loop through all 20 ingredient/measure pairs and display non-empty ones
const ingredientsList = document.getElementById("detail-ingredients");
ingredientsList.innerHTML = "";
for (let i = 1; i <= 20; i++) {
const ingredient = meal[`strIngredient${i}`];
const measure = meal[`strMeasure${i}`];
if (ingredient && ingredient.trim()) {
const li = document.createElement("li");
li.textContent = `${measure ? measure.trim() : ""} ${ingredient.trim()}`;
ingredientsList.appendChild(li);
}
}
// Display cooking instructions
document.getElementById("detail-instructions").textContent = meal.strInstructions;
// Hide results and show the detail view
resultsSection.classList.add("hidden");
filterHeading.classList.add("hidden");
detailSection.classList.remove("hidden");
}
What does this function do?
- It fetches the full meal object from lookup.php?i=ID, which returns every detail about a single meal.
- The for loop checks all 20 ingredient slots. If strIngredient5 is empty, it skips it. If it has text, it combines the measurement and ingredient into a list item.
- The categoryTag.onclick and areaTag.onclick lines prepare clickable tags for the next step. They call functions you'll write in Step 4.
- Finally, it toggles visibility by adding hidden to the results section and removing it from the detail section.
- Save app.js and refresh your browser.
- Search for chicken and click any recipe card. You should see the detail view appear with a 300x300 meal image on the left, a list of ingredients with measurements, and full cooking instructions.
Clicking a card does nothing?
- Open your browser's DevTools console (Cmd+Option+J on macOS, Ctrl+Shift+J on Windows). Look for error messages. A typo in an element ID like detail-img will show a "Cannot set properties of null" error.
- Make sure you placed showMealDetail above the showResults function, not inside it.
- Check that the function name is exactly showMealDetail (capital M, capital D). This must match the call inside displayMeals.
Need more help? Ask: why isn't my showMealDetail function working when I click a card?
Add the clearFilter helper and test navigation
Your app already has a Back to results button wired to showResults. You also have a Clear Filter button that needs a matching function. This small helper resets the filter heading and restores the last search results.
- In app.js, add the following clearFilter function below showResults:
function clearFilter() {
// Hide the filter heading and restore previous results
filterHeading.classList.add("hidden");
if (lastSearchResults.length > 0) {
displayMeals(lastSearchResults);
} else {
resultsGrid.innerHTML = "";
}
}
What does clearFilter do?
When you add category/area filtering in the next step, clicking a filter shows a subset of meals with a heading like "Italian dishes." The clearFilter function hides that heading and restores whatever the user last searched for.
- Save app.js and refresh your browser.
- Search for pasta. Click a recipe card to see the detail view. Click Back to results to return to your search results. The cards should reappear exactly as they were.
Back button doesn't return to results?
- Confirm showResults adds hidden to detailSection and removes it from resultsSection. If both sections are hidden, nothing will show.
- Check for console errors. If you see "clearFilter is not defined," make sure you added the function and saved the file.
Still stuck? Ask: my Back to results button isn't working in my recipe app.
✔️ Awesome, I've got everything!
Great. Double check you've saved both app.js and style.css.
ⓧ I'd like to double check the full code
Here is the full app.js at the end of this step:
const MEALDB_BASE = "https://www.themealdb.com/api/json/v1/1";
const GEMINI_API_KEY = "your-api-key-here";
const GEMINI_URL = "https://generativelanguage.googleapis.com/v1beta/models/gemini-3.5-flash:generateContent";
const searchInput = document.getElementById("search-input");
const searchBtn = document.getElementById("search-btn");
const surpriseBtn = document.getElementById("surprise-btn");
const resultsGrid = document.getElementById("results-grid");
const resultsSection = document.getElementById("results-section");
const detailSection = document.getElementById("detail-section");
const backBtn = document.getElementById("back-btn");
const filterHeading = document.getElementById("filter-heading");
const filterTitle = document.getElementById("filter-title");
const clearFilterBtn = document.getElementById("clear-filter-btn");
const aiInput = document.getElementById("ai-input");
const aiBtn = document.getElementById("ai-btn");
const aiLoading = document.getElementById("ai-loading");
const aiResults = document.getElementById("ai-results");
let lastSearchResults = [];
searchBtn.addEventListener("click", () => {
const query = searchInput.value.trim();
if (query) {
searchMeals(query);
}
});
searchInput.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
const query = searchInput.value.trim();
if (query) {
searchMeals(query);
}
}
});
surpriseBtn.addEventListener("click", getRandomMeal);
backBtn.addEventListener("click", showResults);
clearFilterBtn.addEventListener("click", clearFilter);
async function searchMeals(query) {
const response = await fetch(`${MEALDB_BASE}/search.php?s=${query}`);
const data = await response.json();
filterHeading.classList.add("hidden");
displayMeals(data.meals);
}
async function getRandomMeal() {
const response = await fetch(`${MEALDB_BASE}/random.php`);
const data = await response.json();
filterHeading.classList.add("hidden");
displayMeals(data.meals);
}
function displayMeals(meals) {
resultsGrid.innerHTML = "";
if (!meals) {
resultsGrid.innerHTML = "<p>No meals found. Try a different search.</p>";
return;
}
lastSearchResults = meals;
meals.forEach((meal) => {
const card = document.createElement("div");
card.classList.add("meal-card");
card.innerHTML = `
<img src="${meal.strMealThumb}/medium" alt="${meal.strMeal}">
<div class="meal-card-info">
<h3>${meal.strMeal}</h3>
${meal.strCategory ? `<span class="tag">${meal.strCategory}</span>` : ""}
${meal.strArea ? `<span class="tag">${meal.strArea}</span>` : ""}
</div>
`;
card.addEventListener("click", () => showMealDetail(meal.idMeal));
resultsGrid.appendChild(card);
});
showResults();
}
async function showMealDetail(id) {
const response = await fetch(`${MEALDB_BASE}/lookup.php?i=${id}`);
const data = await response.json();
const meal = data.meals[0];
document.getElementById("detail-img").src = meal.strMealThumb;
document.getElementById("detail-img").alt = meal.strMeal;
document.getElementById("detail-title").textContent = meal.strMeal;
const categoryTag = document.getElementById("detail-category");
categoryTag.textContent = meal.strCategory;
categoryTag.onclick = () => filterByCategory(meal.strCategory);
const areaTag = document.getElementById("detail-area");
areaTag.textContent = meal.strArea;
areaTag.onclick = () => filterByArea(meal.strArea);
const ingredientsList = document.getElementById("detail-ingredients");
ingredientsList.innerHTML = "";
for (let i = 1; i <= 20; i++) {
const ingredient = meal[`strIngredient${i}`];
const measure = meal[`strMeasure${i}`];
if (ingredient && ingredient.trim()) {
const li = document.createElement("li");
li.textContent = `${measure ? measure.trim() : ""} ${ingredient.trim()}`;
ingredientsList.appendChild(li);
}
}
document.getElementById("detail-instructions").textContent = meal.strInstructions;
resultsSection.classList.add("hidden");
filterHeading.classList.add("hidden");
detailSection.classList.remove("hidden");
}
function showResults() {
detailSection.classList.add("hidden");
resultsSection.classList.remove("hidden");
}
function clearFilter() {
filterHeading.classList.add("hidden");
if (lastSearchResults.length > 0) {
displayMeals(lastSearchResults);
} else {
resultsGrid.innerHTML = "";
}
}
And here is the full style.css at the end of this step:
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background-color: #fafafa;
color: #333;
line-height: 1.6;
}
header {
text-align: center;
padding: 2rem 1rem;
background-color: #ff6b35;
color: white;
}
header h1 {
font-size: 2rem;
margin-bottom: 0.5rem;
}
main {
max-width: 1000px;
margin: 0 auto;
padding: 2rem 1rem;
}
.search-bar {
display: flex;
gap: 0.5rem;
margin-bottom: 2rem;
flex-wrap: wrap;
}
.search-bar input {
flex: 1;
padding: 0.75rem 1rem;
font-size: 1rem;
border: 2px solid #ddd;
border-radius: 8px;
min-width: 200px;
}
.search-bar input:focus {
outline: none;
border-color: #ff6b35;
}
button {
padding: 0.75rem 1.5rem;
font-size: 1rem;
border: none;
border-radius: 8px;
cursor: pointer;
font-weight: 600;
}
#search-btn {
background-color: #ff6b35;
color: white;
}
#surprise-btn {
background-color: #2ec4b6;
color: white;
}
#back-btn {
background-color: #666;
color: white;
margin-bottom: 1rem;
}
#clear-filter-btn {
background-color: #666;
color: white;
font-size: 0.875rem;
padding: 0.5rem 1rem;
}
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 1.5rem;
}
.meal-card {
background: white;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
cursor: pointer;
transition: transform 0.2s;
}
.meal-card:hover {
transform: translateY(-4px);
}
.meal-card img {
width: 100%;
height: 180px;
object-fit: cover;
}
.meal-card-info {
padding: 1rem;
}
.meal-card-info h3 {
font-size: 1rem;
margin-bottom: 0.5rem;
}
.meal-card-info .tag {
display: inline-block;
font-size: 0.75rem;
padding: 0.25rem 0.5rem;
border-radius: 4px;
margin-right: 0.25rem;
}
.tag {
background-color: #f0f0f0;
color: #555;
}
.tag.clickable {
cursor: pointer;
background-color: #e8f4f8;
color: #2ec4b6;
font-weight: 600;
}
.tag.clickable:hover {
background-color: #2ec4b6;
color: white;
}
.hidden {
display: none;
}
#filter-heading {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1rem;
}
#meal-detail {
display: flex;
gap: 2rem;
flex-wrap: wrap;
}
#detail-img {
width: 300px;
height: 300px;
object-fit: cover;
border-radius: 12px;
}
#detail-info {
flex: 1;
min-width: 280px;
}
#detail-title {
font-size: 1.5rem;
margin-bottom: 0.5rem;
}
#detail-tags {
margin-bottom: 1rem;
}
#detail-ingredients {
margin-bottom: 1rem;
padding-left: 1.5rem;
}
#detail-ingredients li {
margin-bottom: 0.25rem;
}
#detail-instructions {
white-space: pre-line;
}
Your detail view is working. Next up, you'll make the category and area tags clickable so users can explore related dishes and fall deeper into the rabbit hole.
Add the Rabbit Hole (Category and Area Exploration)
You've built a working search, card display, and detail view. Now it's time to add the feature that makes this app truly addictive: the rabbit hole.
When a user views a meal's details, they see category and area tags. Clicking one of these tags will fetch ALL meals in that category or region, creating an endless exploration loop where one recipe leads to the next.
In this step, get ready to:
- Write the filterByCategory function to fetch and display meals by category.
- Write the filterByArea function to fetch and display meals by area.
- Test the full rabbit hole exploration chain.
Write the filterByCategory function
In the previous step, showMealDetail already wires up the category tag's onclick to call filterByCategory. That function doesn't exist yet, so clicking the tag does nothing. Let's define it now.
- In app.js, scroll down to find the closing brace of the showMealDetail function.
- Add the filterByCategory function below it by copying this code:
async function filterByCategory(category) {
// Fetch all meals in this category from TheMealDB
const response = await fetch(`${MEALDB_BASE}/filter.php?c=${category}`);
const data = await response.json();
// Update the heading to show which filter is active
filterTitle.textContent = `${category} recipes`;
filterHeading.classList.remove("hidden");
// Switch from detail view back to results grid
detailSection.classList.add("hidden");
resultsSection.classList.remove("hidden");
// Reuse displayMeals to render the filtered results as cards
displayMeals(data.meals);
}
What does this code do?
This function takes a category name (like "Seafood" or "Dessert") and fetches every meal tagged with that category from TheMealDB.
- filter.php?c= is the category filter endpoint. The c stands for category.
- filterTitle.textContent updates the heading above the grid to show what the learner filtered by (e.g. "Seafood recipes").
- The function hides the detail section and shows the results grid before calling displayMeals to render the filtered meals as cards.
Don't save yet. You'll add the companion function next.
Write the filterByArea function
The area filter follows the exact same pattern as the category filter. The only differences are the endpoint parameter (a instead of c) and the heading text.
- Directly below filterByCategory, add the filterByArea function by copying this code:
async function filterByArea(area) {
// Fetch all meals from this region
const response = await fetch(`${MEALDB_BASE}/filter.php?a=${area}`);
const data = await response.json();
// Update the heading to show the active area filter
filterTitle.textContent = `${area} dishes`;
filterHeading.classList.remove("hidden");
// Switch from detail view back to results grid
detailSection.classList.add("hidden");
resultsSection.classList.remove("hidden");
// Reuse displayMeals to render the filtered results as cards
displayMeals(data.meals);
}
What's the difference between these two functions?
Both functions follow the same fetch-then-display pattern. The only differences are:
- filterByCategory uses filter.php?c= and labels the heading "[Category] recipes".
- filterByArea uses filter.php?a= and labels the heading "[Area] dishes".
- Save app.js.
- Refresh your browser and search for "chicken".
- Click any recipe card to open the detail view.
- Click the category tag (the teal-colored label). You should see a heading like "Chicken recipes" appear above a new grid of meal cards.
Clicking the tag does nothing?
- Open your browser's DevTools console (press F12) and look for errors. A ReferenceError means the function name is misspelled or the code wasn't saved.
- Make sure you placed both functions AFTER the showMealDetail function and BEFORE the showResults function.
- Check that the function names match exactly: filterByCategory and filterByArea (case-sensitive).
Why aren't my category or area tags working when I click them?
Test the rabbit hole exploration chain
The filter endpoints return a reduced response compared to the search endpoint. Each meal object only contains the name, thumbnail, and ID. It does not include ingredients, instructions, or other details.
Why does the chain still work with less data?
Your displayMeals function creates cards that only need a name, thumbnail, and ID. When you click one of those cards, it calls showMealDetail(meal.idMeal) which performs a FRESH lookup to get the full details.
This is the power of API chaining. The filter endpoint gives you just enough data to display cards, and the lookup endpoint fills in the rest when the user wants it. Each click triggers a new API call, keeping the exploration going indefinitely.
- From the filtered results, click any meal card. You should see the full detail view with ingredients, instructions, and new clickable tags.
- Click the area tag (e.g. "Italian" or "British") in that detail view. A new set of cards from that region should appear.
- Click the **Clear Filter** button above the cards. The filter heading should disappear.
You now have a fully working rabbit hole. One recipe leads to a category, which leads to another recipe, which leads to a cuisine, and on and on.
Cards appear but clicking one does nothing?
- The filter endpoint returns idMeal (not id). Check that your displayMeals function passes meal.idMeal to showMealDetail.
- Open the DevTools console and check for network errors. If you see a 404, the meal ID might not be passing correctly.
Help me figure out why clicking filtered meal cards doesn't show the detail view.
✔️ Awesome, I've got everything!
Great. Double check you've saved app.js and your rabbit hole is working end-to-end.
ⓧ I'd like to double check the full code
Here is the full app.js file at this point. Compare it with yours:
const MEALDB_BASE = "https://www.themealdb.com/api/json/v1/1";
const GEMINI_API_KEY = "your-api-key-here";
const GEMINI_URL = "https://generativelanguage.googleapis.com/v1beta/models/gemini-3.5-flash:generateContent";
const searchInput = document.getElementById("search-input");
const searchBtn = document.getElementById("search-btn");
const surpriseBtn = document.getElementById("surprise-btn");
const resultsGrid = document.getElementById("results-grid");
const resultsSection = document.getElementById("results-section");
const detailSection = document.getElementById("detail-section");
const backBtn = document.getElementById("back-btn");
const filterHeading = document.getElementById("filter-heading");
const filterTitle = document.getElementById("filter-title");
const clearFilterBtn = document.getElementById("clear-filter-btn");
const aiInput = document.getElementById("ai-input");
const aiBtn = document.getElementById("ai-btn");
const aiLoading = document.getElementById("ai-loading");
const aiResults = document.getElementById("ai-results");
let lastSearchResults = [];
searchBtn.addEventListener("click", () => {
const query = searchInput.value.trim();
if (query) {
searchMeals(query);
}
});
searchInput.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
const query = searchInput.value.trim();
if (query) {
searchMeals(query);
}
}
});
surpriseBtn.addEventListener("click", getRandomMeal);
backBtn.addEventListener("click", showResults);
clearFilterBtn.addEventListener("click", clearFilter);
async function searchMeals(query) {
const response = await fetch(`${MEALDB_BASE}/search.php?s=${query}`);
const data = await response.json();
filterHeading.classList.add("hidden");
displayMeals(data.meals);
}
async function getRandomMeal() {
const response = await fetch(`${MEALDB_BASE}/random.php`);
const data = await response.json();
filterHeading.classList.add("hidden");
displayMeals(data.meals);
}
function displayMeals(meals) {
resultsGrid.innerHTML = "";
if (!meals) {
resultsGrid.innerHTML = "<p>No meals found. Try a different search.</p>";
return;
}
lastSearchResults = meals;
meals.forEach((meal) => {
const card = document.createElement("div");
card.classList.add("meal-card");
card.innerHTML = `
<img src="${meal.strMealThumb}/medium" alt="${meal.strMeal}">
<div class="meal-card-info">
<h3>${meal.strMeal}</h3>
${meal.strCategory ? `<span class="tag">${meal.strCategory}</span>` : ""}
${meal.strArea ? `<span class="tag">${meal.strArea}</span>` : ""}
</div>
`;
card.addEventListener("click", () => showMealDetail(meal.idMeal));
resultsGrid.appendChild(card);
});
showResults();
}
async function showMealDetail(id) {
const response = await fetch(`${MEALDB_BASE}/lookup.php?i=${id}`);
const data = await response.json();
const meal = data.meals[0];
document.getElementById("detail-img").src = meal.strMealThumb;
document.getElementById("detail-img").alt = meal.strMeal;
document.getElementById("detail-title").textContent = meal.strMeal;
const categoryTag = document.getElementById("detail-category");
categoryTag.textContent = meal.strCategory;
categoryTag.onclick = () => filterByCategory(meal.strCategory);
const areaTag = document.getElementById("detail-area");
areaTag.textContent = meal.strArea;
areaTag.onclick = () => filterByArea(meal.strArea);
const ingredientsList = document.getElementById("detail-ingredients");
ingredientsList.innerHTML = "";
for (let i = 1; i <= 20; i++) {
const ingredient = meal[`strIngredient${i}`];
const measure = meal[`strMeasure${i}`];
if (ingredient && ingredient.trim()) {
const li = document.createElement("li");
li.textContent = `${measure ? measure.trim() : ""} ${ingredient.trim()}`;
ingredientsList.appendChild(li);
}
}
document.getElementById("detail-instructions").textContent = meal.strInstructions;
resultsSection.classList.add("hidden");
filterHeading.classList.add("hidden");
detailSection.classList.remove("hidden");
}
async function filterByCategory(category) {
const response = await fetch(`${MEALDB_BASE}/filter.php?c=${category}`);
const data = await response.json();
filterTitle.textContent = `${category} recipes`;
filterHeading.classList.remove("hidden");
detailSection.classList.add("hidden");
resultsSection.classList.remove("hidden");
displayMeals(data.meals);
}
async function filterByArea(area) {
const response = await fetch(`${MEALDB_BASE}/filter.php?a=${area}`);
const data = await response.json();
filterTitle.textContent = `${area} dishes`;
filterHeading.classList.remove("hidden");
detailSection.classList.add("hidden");
resultsSection.classList.remove("hidden");
displayMeals(data.meals);
}
function showResults() {
detailSection.classList.add("hidden");
resultsSection.classList.remove("hidden");
}
function clearFilter() {
filterHeading.classList.add("hidden");
if (lastSearchResults.length > 0) {
displayMeals(lastSearchResults);
} else {
resultsGrid.innerHTML = "";
}
}
Your recipe explorer now has a real exploration flow. Next up, you'll add an AI-powered feature that suggests meals based on ingredients you have on hand.
Secret mission
AI-Powered "What Can I Cook?"
Your recipe app already finds meals by name. But what if you could type in whatever ingredients you have in the fridge and get meal ideas instantly? In this secret mission, you'll wire up the Google Gemini API to give your app AI-powered ingredient suggestions.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally and uses the Gemini API free tier, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- Gemini API key (free tier, no charges for this usage level).
- Local project folder recipe-rabbit-hole on your Desktop with index.html, style.css, and app.js.
Keep everything running
No action needed. Choose this if you are still actively building or want to keep experimenting with the app.
- Your Gemini API key remains active on the free tier at no cost.
- Your local project files stay on your Desktop for easy access.
- TheMealDB requires no key management since it uses the free test key 1 in the URL path.
Pause - I'll come back to this later
There is nothing running that needs to be paused. Your project is a set of static files that only run when you open them in a browser.
- Close the browser tab running your app.
- Close VS Code if you are done for now.
- Everything will be exactly as you left it when you come back.
Delete - I don't want to use this again
Remove all project resources and start fresh. Follow both steps below to fully clean up.
- Delete your Gemini API key:
- Delete your local project files:
rm -rf ~/Desktop/recipe-rabbit-hole
On Windows, open PowerShell and run:
Remove-Item -Recurse -Force ~\Desktop\recipe-rabbit-hole
Nice Work!
Nice Work!
You just built a fully functional recipe exploration app that pulls data from two completely different APIs and chains multiple calls together into a seamless browsing experience.
You've learned how to:
- Build a multi-API web app that chains search, detail lookup, and category filtering calls from TheMealDB into a connected exploration flow.
- Create a "rabbit hole" navigation pattern where clicking a meal's category or cuisine fetches related dishes and keeps the exploration going deeper.
- Handle different JSON response shapes from the same API, adapting your code to work with both full meal objects and filtered summaries that only include an ID, name, and thumbnail.
- Integrate Google Gemini AI as a second API with header-based authentication to provide ingredient-based meal suggestions.
Ready to quiz yourself?