Recipe Finder App
Build a Vue.js recipe finder with ingredient search, filters, and saved favorites
Time to implement the project: ~ 16-26 hours
- Vue.js
- Reactive State
- API Integration
- Computed Properties
- Conditional Rendering
- Bootstrap
- Local Storage
In this intermediate Vue.js project, you will build a Recipe Finder application that allows users to search for recipes based on selected ingredients. The app must send queries to an external recipes API, retrieve matching results, and display them as a responsive list with images, titles, and short summaries. Users should be able to open a recipe to view full details such as ingredients, preparation steps, and cooking time.
You will also implement dietary preference filters, such as vegetarian, vegan, or gluten-free, which dynamically refine the results without requiring a new search. Users must be able to save favorite recipes locally and revisit them later. Bootstrap should be used to structure layouts, forms, cards, and grids, ensuring a clean, familiar UI that fits naturally into Vue’s template-driven development style.
Learning Objectives and Practical Focus
This project is designed to strengthen your ability to build data-driven interfaces with Vue.js. You will practice managing reactive state that flows through multiple UI layers: search input, filters, result lists, and detail views. Ingredient-based search requires careful handling of user input and API parameters, which reflects real-world frontend challenges.
The app also reinforces computed properties and derived state. Filters and favorites should update the UI automatically, without manual DOM manipulation, which is considered a core Vue competency at the intermediate level.
What You Should Know Before Starting
This project assumes you are already comfortable with Vue fundamentals and ready to manage multiple reactive concerns within a single application.
- Vue 3 basics, including reactive data and template syntax
- Experience using computed properties for filtered views
- Understanding of component communication and props
- Basic API request handling and async UI states
- Comfort working with Bootstrap layout and form components
- Local storage usage for saving user-selected data
Core Features and Functional Requirements
A strong Recipe Finder App feels helpful and predictable. Searches return relevant results, filters apply instantly, and favorites persist across sessions. These requirements emphasize correctness, responsiveness, and clean state flow rather than visual complexity.
| Requirement | Explanation |
| Ingredient-based recipe search | Searching by ingredients tests structured input handling and API parameter construction. |
| Recipe list with preview cards | Cards organize results clearly and reinforce component-based rendering. |
| Detailed recipe view | Detail pages validate conditional rendering and data-driven UI expansion. |
| Dietary preference filters | Filters train computed logic and real-time UI updates without refetching. |
| Favorites system with persistence | Saving favorites demonstrates durable client-side state management. |
| Loading and empty states | Clear feedback improves usability during network delays or no-result cases. |
| Responsive Bootstrap layout | Bootstrap grids ensure readable results on mobile and desktop screens. |
Implementation Tips for a Scalable Vue App
Start by defining a clear data model: raw API results, active ingredient list, selected dietary filters, and favorites. Keep API communication isolated in a service or composable so components remain focused on rendering. Use computed properties to derive filtered results instead of mutating the original data. For favorites, store a normalized structure so restoring state is fast and reliable. When filtering logic lives in computed properties, Vue reactivity stays predictable.
- Normalize ingredient input to avoid duplicate or conflicting searches
- Keep filter logic separate from API logic for easier debugging
- Limit the number of stored favorites to prevent uncontrolled growth
- Handle missing images or instructions gracefully
- Use Bootstrap utility classes to maintain consistent spacing and alignment
- Test combinations of filters to ensure results stay accurate
- Persist favorites only after successful data validation
Common Mistakes When Building a Recipe Finder App
1. Treating ingredient search as one plain text string
Ingredient-based search is different from a normal keyword search. Users may type ingredients with commas, spaces, uppercase letters, duplicates, or small mistakes:
Tomato, tomato, Cheese. A common mistake is sending this raw input directly to the API. The result is inconsistent search behavior, duplicated parameters,
and confusing no-result states.
Problematic approach:
const ingredients = ref("");
async function searchRecipes() {
const response = await fetch(
`/api/recipes?ingredients=${ingredients.value}`
);
recipes.value = await response.json();
}
This code does not normalize the input. The API receives exactly what the user typed, including duplicate ingredients, unnecessary spaces, and inconsistent casing.
Better approach:
const ingredientInput = ref("");
const normalizedIngredients = computed(() => {
return ingredientInput.value
.split(",")
.map((ingredient) => ingredient.trim().toLowerCase())
.filter(Boolean)
.filter((ingredient, index, list) => {
return list.indexOf(ingredient) === index;
});
});
async function searchRecipes() {
if (!normalizedIngredients.value.length) {
error.value = "Add at least one ingredient.";
return;
}
const query = normalizedIngredients.value.join(",");
const response = await fetch(
`/api/recipes?ingredients=${encodeURIComponent(query)}`
);
recipes.value = await response.json();
}
Template example:
<input
v-model="ingredientInput"
type="text"
class="form-control"
placeholder="tomato, cheese, pasta"
/>
<div class="mt-2">
<span
v-for="ingredient in normalizedIngredients"
:key="ingredient"
class="badge bg-secondary me-2"
>
{{ ingredient }}
</span>
</div>
Pay attention to: Convert ingredient input into a clean list before searching. Normalize casing, remove empty values, prevent duplicates, and show users exactly which ingredients will be used in the request.
2. Mutating API results directly when applying dietary filters
Dietary filters such as vegetarian, vegan, gluten-free, dairy-free, or low-carb should refine the visible results without destroying the original API response. A frequent mistake is replacing the main recipes array every time a filter changes. After two or three filter changes, the original result set is gone, and clearing filters no longer restores the full list.
Problematic code:
const recipes = ref([]);
function applyVegetarianFilter() {
recipes.value = recipes.value.filter((recipe) => {
return recipe.diets.includes("vegetarian");
});
}
function applyGlutenFreeFilter() {
recipes.value = recipes.value.filter((recipe) => {
return recipe.diets.includes("gluten-free");
});
}
This approach permanently changes recipes. If the user disables the filter, the app cannot recover the removed recipes without making another API call.
Better approach:
const recipes = ref([]);
const activeDietFilters = ref([]);
const visibleRecipes = computed(() => {
if (!activeDietFilters.value.length) {
return recipes.value;
}
return recipes.value.filter((recipe) => {
const recipeDiets = recipe.diets || [];
return activeDietFilters.value.every((diet) => {
return recipeDiets.includes(diet);
});
});
});
function toggleDietFilter(diet) {
if (activeDietFilters.value.includes(diet)) {
activeDietFilters.value = activeDietFilters.value.filter((item) => {
return item !== diet;
});
return;
}
activeDietFilters.value = [...activeDietFilters.value, diet];
}
Template usage:
<button
v-for="diet in dietOptions"
:key="diet.value"
type="button"
class="btn me-2"
:class="activeDietFilters.includes(diet.value) ? 'btn-success' : 'btn-outline-success'"
@click="toggleDietFilter(diet.value)"
>
{{ diet.label }}
</button>
<RecipeCard
v-for="recipe in visibleRecipes"
:key="recipe.id"
:recipe="recipe"
/>
Pay attention to: Keep raw API results unchanged. Use computed properties for filtered views so users can turn filters on and off without losing the original search results.
3. Assuming every recipe has complete details
Recipe APIs often return incomplete or inconsistent data. Some recipes may not have images, cooking time, dietary tags, full instructions, ingredient amounts, or source links. If your components assume every field exists, the UI may show broken images, empty cards, or JavaScript errors when users open a recipe detail view.
Problematic component:
<template>
<div class="card">
<img :src="recipe.image" class="card-img-top" :alt="recipe.title" />
<div class="card-body">
<h3>{{ recipe.title }}</h3>
<p>Ready in {{ recipe.readyInMinutes }} minutes</p>
<p>{{ recipe.instructions.slice(0, 160) }}...</p>
</div>
</div>
</template>
This can break if recipe.image, readyInMinutes, or instructions is missing. A good recipe app should degrade gracefully.
Better approach:
const FALLBACK_IMAGE = "/images/recipe-placeholder.jpg";
function getRecipeImage(recipe) {
return recipe.image || FALLBACK_IMAGE;
}
function getCookingTime(recipe) {
if (!recipe.readyInMinutes) {
return "Time not specified";
}
return `${recipe.readyInMinutes} minutes`;
}
function getShortInstructions(recipe) {
if (!recipe.instructions) {
return "Open the recipe details to see available preparation information.";
}
return `${recipe.instructions.slice(0, 160)}...`;
}
Safer template:
<div class="card h-100">
<img
:src="getRecipeImage(recipe)"
class="card-img-top"
:alt="recipe.title ? `${recipe.title} image` : 'Recipe image'"
/>
<div class="card-body">
<h3 class="h5">{{ recipe.title || "Untitled recipe" }}</h3>
<p class="text-muted">{{ getCookingTime(recipe) }}</p>
<p>{{ getShortInstructions(recipe) }}</p>
</div>
</div>
Pay attention to: Build recipe cards and detail pages for imperfect data. Add fallback images, safe text formatting, missing-time labels, and clear empty instructions instead of letting the layout break.
4. Saving favorites without a stable recipe shape
Favorites make the Recipe Finder App more useful, but they can also become messy if you save full API responses directly. API objects may contain large nested fields, inconsistent property names, or data you do not need. If the API response changes later, old favorites stored in localStorage may become difficult to render.
Problematic code:
function saveFavorite(recipe) {
const favorites = JSON.parse(localStorage.getItem("favorites")) || [];
favorites.push(recipe);
localStorage.setItem("favorites", JSON.stringify(favorites));
}
This stores the entire recipe object exactly as it came from the API. It also allows duplicates and does not validate the saved data.
Better approach:
function createFavoriteRecipe(recipe) {
return {
id: String(recipe.id),
title: recipe.title || "Untitled recipe",
image: recipe.image || "",
readyInMinutes: recipe.readyInMinutes || null,
diets: recipe.diets || [],
sourceUrl: recipe.sourceUrl || "",
savedAt: new Date().toISOString()
};
}
function loadFavorites() {
const saved = localStorage.getItem("favoriteRecipes");
if (!saved) {
return [];
}
try {
const parsed = JSON.parse(saved);
if (!Array.isArray(parsed)) {
return [];
}
return parsed.filter((recipe) => {
return recipe.id && recipe.title;
});
} catch {
localStorage.removeItem("favoriteRecipes");
return [];
}
}
function saveFavorite(recipe) {
const favorites = loadFavorites();
const favoriteRecipe = createFavoriteRecipe(recipe);
const alreadySaved = favorites.some((item) => {
return item.id === favoriteRecipe.id;
});
if (alreadySaved) {
return favorites;
}
const updatedFavorites = [...favorites, favoriteRecipe];
localStorage.setItem("favoriteRecipes", JSON.stringify(updatedFavorites));
return updatedFavorites;
}
Pay attention to: Save a small, stable favorite object instead of the entire API response. Prevent duplicates, validate restored data, and keep the favorite structure easy to display later.
5. Showing one generic empty state for every problem
A recipe app can have several empty states: no ingredients entered, search in progress, no API results, filters hiding all results, failed request, or no saved favorites. If all of these situations show the same blank area or the same “No recipes found” message, users will not know what to do next.
Problematic template:
<div v-if="!recipes.length">
No recipes found.
</div>
<RecipeCard
v-for="recipe in visibleRecipes"
:key="recipe.id"
:recipe="recipe"
/>
This does not distinguish between “you have not searched yet,” “the API failed,” and “your filters are too strict.”
Better approach:
const hasSearched = ref(false);
const loading = ref(false);
const error = ref("");
const emptyState = computed(() => {
if (!hasSearched.value) {
return {
title: "Search by ingredients",
text: "Add ingredients you have at home to find matching recipes."
};
}
if (error.value) {
return {
title: "Recipes could not be loaded",
text: "Check your connection and try the search again."
};
}
if (recipes.value.length && !visibleRecipes.value.length) {
return {
title: "No recipes match these filters",
text: "Try removing one dietary filter or changing your ingredients."
};
}
if (!recipes.value.length) {
return {
title: "No recipes found",
text: "Try broader ingredients such as chicken, rice, tomato, or pasta."
};
}
return null;
});
Template example:
<div v-if="loading" class="alert alert-info">
Searching recipes...
</div>
<div v-else-if="emptyState" class="alert alert-light border">
<h3 class="h5">{{ emptyState.title }}</h3>
<p class="mb-0">{{ emptyState.text }}</p>
</div>
<div v-else class="row g-4">
<div
v-for="recipe in visibleRecipes"
:key="recipe.id"
class="col-12 col-md-6 col-lg-4"
>
<RecipeCard :recipe="recipe" />
</div>
</div>
Pay attention to: Empty states should explain the situation and guide the next action. Separate initial, loading, error, no-result, filter-empty, and no-favorites states.
By completing this project, you'll gain hands-on experience building a Vue.js application that combines API-driven search, reactive filtering, and persistent user preferences. You will strengthen your understanding of computed state, component structure, and data flow while delivering a practical interface styled with Bootstrap. This project prepares you for more complex Vue applications that rely on dynamic data, user personalization, and scalable UI logic.
Reference Implementations Worth Studying
Focused Vue recipe finder reference:
yubinjodev - Epicurious Clone
This is the most directly aligned reference for the Recipe Finder App because it is a Vue.js recipe finder inspired by Epicurious. It uses TheMealDB API for recipe data and includes the key flow your own project needs: search from the homepage, browse results, and open a recipe detail page with ingredients and instructions.
Pay particular attention to:
- How the project separates the homepage, results page, and recipe detail route.
- How Vue Router supports direct navigation to a specific recipe with
/recipe/:id. - How API data is transformed into a browsable recipe experience.
- How recipe details such as ingredients and instructions are presented after the user selects a result.
- How performance, accessibility, SEO, and best-practice checks can improve a portfolio project beyond basic functionality.
Use this repository as the closest implementation reference. It is especially useful for understanding route structure and the difference between search results and full recipe details.
More complete recipe platform reference:
davis0011 - Recipe Website Front-End
This implementation is more product-oriented because it is the frontend of a full-stack recipe website. It uses Vue.js, Axios, Bootstrap, JavaScript, and the Spoonacular API. The project includes user registration, saved recipes, user-created recipes, random recipes on the front page, recently viewed recipes for logged-in users, advanced search, sorting, and a full recipe view.
When studying the code, focus on:
- How Axios is used to communicate with backend and recipe API data sources.
- How search becomes more powerful when users can filter by name, cuisine, diet, and intolerances.
- How saved recipes and recently viewed recipes add personalization to the experience.
- How sorting by popularity or preparation time changes the usefulness of search results.
What makes this reference valuable is its broader product scope. Use it to understand how a simple Recipe Finder can grow into a real recipe platform with accounts, personalization, advanced search, sorting, and saved content.
Alternative ingredient-to-video recipe flow:
hayleyarodgers - Recipe Findr
This repository is useful as an alternative direction because it focuses on a different user outcome: helping users cook with ingredients they already have and then finding video tutorials. The app asks for up to three ingredients, searches Spoonacular for matching recipe names, and then uses the YouTube API to find related recipe tutorial videos.
While reviewing this project, examine:
- How ingredient-first search can make the app feel more practical than a generic recipe search box.
- How combining Spoonacular and YouTube creates a richer recipe discovery flow.
- How recipe history helps users return to something they already selected.
- How the product goal connects to reducing shopping friction and using available ingredients.
Use this implementation for feature inspiration rather than framework structure. It shows how a Recipe Finder App can become more helpful by connecting recipe search with instructional videos and recently selected recipe history.