Ever found yourself staring into your fridge, a culinary adventurer facing a daunting quest: what can I *actually* make with these ingredients? The struggle is real. Finding recipes that perfectly match what you have on hand can be a time-consuming scroll through endless websites. This tutorial will guide you through building a simple, yet effective, web-based recipe ingredient finder using Next.js. We’ll create a user-friendly interface where users can input their available ingredients and receive a list of matching recipes. This project is a great way to learn core Next.js concepts and build a practical application.
Why Build a Recipe Ingredient Finder?
Beyond the convenience of solving the “what’s for dinner?” dilemma, this project provides a fantastic learning opportunity. You’ll gain hands-on experience with:
- Data Fetching: Learn how to fetch recipe data from an external API.
- Dynamic Routing: Understand how to create dynamic routes for displaying individual recipe details.
- User Input and State Management: Handle user input and manage the application’s state.
- Component Composition: Build reusable components for a clean and organized codebase.
This project is also easily expandable. You can add features like user authentication, recipe saving, and more, making it a valuable addition to your portfolio and a practical tool for everyday use.
Prerequisites
Before we dive in, make sure you have the following:
- Node.js and npm (or yarn): Installed on your computer.
- A Code Editor: Such as VS Code, Sublime Text, or Atom.
- Basic HTML, CSS, and JavaScript knowledge: Familiarity with these languages will be helpful, but not strictly required.
Step-by-Step Guide
1. Setting Up Your Next.js Project
Let’s start by creating a new Next.js project. Open your terminal and run the following command:
npx create-next-app recipe-finder
This command will create a new directory called `recipe-finder` with the basic structure of a Next.js application. Navigate into the project directory:
cd recipe-finder
Now, start the development server:
npm run dev
Open your browser and go to `http://localhost:3000`. You should see the default Next.js welcome page.
2. Project Structure and File Organization
Let’s take a quick look at the project structure. The key directories and files we’ll be working with are:
- `pages/`: This directory contains your application’s pages. Each file in this directory represents a route. For example, `pages/index.js` is the home page, and `pages/recipe/[id].js` will be the recipe detail page.
- `components/`: This directory will hold reusable React components.
- `styles/`: This directory is where you’ll store your CSS or styling files.
- `public/`: This directory is for static assets like images.
3. Creating the Ingredient Input Form
Let’s create the ingredient input form. We’ll start by modifying the `pages/index.js` file. Replace the content of `index.js` with the following code:
import { useState } from 'react';
export default function Home() {
const [ingredients, setIngredients] = useState('');
const [recipes, setRecipes] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const handleSubmit = async (e) => {
e.preventDefault();
setLoading(true);
setError(null);
setRecipes([]); // Clear previous results
try {
const response = await fetch(
`/api/recipes?ingredients=${ingredients}`
);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
setRecipes(data);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
return (
<div className="container">
<h1>Recipe Ingredient Finder</h1>
<form onSubmit={handleSubmit} className="form">
<label htmlFor="ingredients">Enter Ingredients (comma-separated):</label>
<input
type="text"
id="ingredients"
value={ingredients}
onChange={(e) => setIngredients(e.target.value)}
placeholder="e.g., chicken, rice, vegetables"
/>
<button type="submit" disabled={loading}>
{loading ? 'Searching...' : 'Find Recipes'}
</button>
</form>
{error && <p className="error">Error: {error}</p>}
{recipes.length > 0 && (
<div className="recipe-list">
<h2>Recipes:</h2>
<ul>
{recipes.map((recipe) => (
<li key={recipe.id}>
<a href={`/recipe/${recipe.id}`}>{recipe.title}</a>
</li>
))}
</ul>
</div>
)}
{loading && <p>Loading...</p>}
</div>
);
}
Let’s break down this code:
- Import `useState`: We import the `useState` hook from React to manage the component’s state.
- `ingredients` state: This state variable holds the user’s input from the ingredient input field.
- `recipes` state: This state variable stores the list of recipes fetched from the API.
- `loading` state: This boolean state variable indicates whether the application is currently fetching data.
- `error` state: This state variable stores any error messages that occur during the API call.
- `handleSubmit` function: This function is called when the form is submitted. It prevents the default form submission behavior, sets the loading state to true, and calls the API to fetch recipes.
- JSX (HTML-like syntax): The code returns JSX that renders the form and displays the results.
- Conditional Rendering: The code uses conditional rendering to display the loading message, error message, and the list of recipes based on the state variables.
Now, let’s add some basic styling. Create a file named `styles/Home.module.css` and add the following CSS:
.container {
max-width: 800px;
margin: 2rem auto;
padding: 1rem;
border: 1px solid #ccc;
border-radius: 5px;
}
.form {
display: flex;
flex-direction: column;
margin-bottom: 1rem;
}
.form label {
margin-bottom: 0.5rem;
font-weight: bold;
}
.form input {
padding: 0.5rem;
margin-bottom: 1rem;
border: 1px solid #ccc;
border-radius: 4px;
}
.form button {
padding: 0.75rem 1rem;
background-color: #4CAF50;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.form button:disabled {
background-color: #cccccc;
cursor: not-allowed;
}
.error {
color: red;
margin-bottom: 1rem;
}
.recipe-list ul {
list-style: none;
padding: 0;
}
.recipe-list li {
margin-bottom: 0.5rem;
}
.recipe-list a {
text-decoration: none;
color: #007bff;
}
Import the CSS file into `pages/index.js` by adding this line at the top of the file:
import styles from '../styles/Home.module.css';
And apply the styles to the main `div` container by adding `className={styles.container}`.
Your `pages/index.js` file should now look like this:
import { useState } from 'react';
import styles from '../styles/Home.module.css';
export default function Home() {
const [ingredients, setIngredients] = useState('');
const [recipes, setRecipes] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const handleSubmit = async (e) => {
e.preventDefault();
setLoading(true);
setError(null);
setRecipes([]); // Clear previous results
try {
const response = await fetch(
`/api/recipes?ingredients=${ingredients}`
);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
setRecipes(data);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
return (
<div className={styles.container}>
<h1>Recipe Ingredient Finder</h1>
<form onSubmit={handleSubmit} className={styles.form}>
<label htmlFor="ingredients">Enter Ingredients (comma-separated):</label>
<input
type="text"
id="ingredients"
value={ingredients}
onChange={(e) => setIngredients(e.target.value)}
placeholder="e.g., chicken, rice, vegetables"
/>
<button type="submit" disabled={loading}>
{loading ? 'Searching...' : 'Find Recipes'}
</button>
</form>
{error && <p className={styles.error}>Error: {error}</p>}
{recipes.length > 0 && (
<div className={styles['recipe-list']}>
<h2>Recipes:</h2>
<ul>
{recipes.map((recipe) => (
<li key={recipe.id}>
<a href={`/recipe/${recipe.id}`}>{recipe.title}</a>
</li>
))}
</ul>
</div>
)}
{loading && <p>Loading...</p>}
</div>
);
}
4. Creating the API Route to Fetch Recipes
Next.js makes it easy to create API routes. We’ll create an API route to handle the recipe search. Create a new file named `pages/api/recipes.js` and add the following code:
// pages/api/recipes.js
export default async function handler(req, res) {
const { ingredients } = req.query;
if (!ingredients) {
return res.status(400).json({ error: 'Ingredients are required.' });
}
try {
// Replace with your actual API call. This example uses a mock API.
const apiKey = process.env.RECIPE_API_KEY; //Consider storing this in .env file
const apiUrl = `https://api.spoonacular.com/recipes/findByIngredients?ingredients=${ingredients}&apiKey=${apiKey}&number=5`; // Example API
const response = await fetch(apiUrl);
if (!response.ok) {
throw new Error(`API request failed with status ${response.status}`);
}
const data = await response.json();
// Transform the data to a more suitable format
const recipes = data.map(recipe => ({
id: recipe.id,
title: recipe.title,
image: recipe.image,
}));
res.status(200).json(recipes);
} catch (error) {
console.error('API Error:', error);
res.status(500).json({ error: 'Failed to fetch recipes' });
}
}
In this code:
- `req.query.ingredients`: This retrieves the ingredients entered by the user from the query parameters.
- Error Handling: Checks if ingredients are provided and returns an error if not.
- API Call: This example uses the Spoonacular API. You will need to sign up for a free API key at https://spoonacular.com/food-api. Then, replace the placeholder with your actual API key.
- `fetch` API: The code calls the Spoonacular API based on the ingredients provided.
- Error Handling: Includes error handling for API request failures.
- Response: Returns the recipe data in JSON format.
Important: To use the Spoonacular API (or any external API), you’ll need to sign up for an API key. You should also consider storing your API key in a `.env.local` file (e.g., `RECIPE_API_KEY=YOUR_API_KEY`) and accessing it via `process.env.RECIPE_API_KEY` to keep your API key secure.
5. Implementing the Recipe Detail Page
Now, let’s create a dynamic route to display the details of each recipe. Create a file named `pages/recipe/[id].js`. This file will handle requests to `/recipe/[recipeId]`. Add the following code:
import { useRouter } from 'next/router';
import { useState, useEffect } from 'react';
import styles from '../../styles/RecipeDetail.module.css';
export default function RecipeDetail() {
const router = useRouter();
const { id } = router.query;
const [recipe, setRecipe] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
if (!id) return; // Prevent fetching before the id is available
const fetchRecipe = async () => {
setLoading(true);
setError(null);
try {
const apiKey = process.env.RECIPE_API_KEY; // Use your API key
const apiUrl = `https://api.spoonacular.com/recipes/${id}/information?apiKey=${apiKey}`;
const response = await fetch(apiUrl);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
setRecipe(data);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
fetchRecipe();
}, [id]);
if (loading) return <p>Loading...</p>;
if (error) return <p className={styles.error}>Error: {error}</p>;
if (!recipe) return <p>Recipe not found.</p>;
return (
<div className={styles.container}>
<h1>{recipe.title}</h1>
<img src={recipe.image} alt={recipe.title} className={styles.recipeImage} />
<div className={styles.recipeInfo}>
<h2>Ingredients:</h2>
<ul>
{recipe.extendedIngredients.map((ingredient) => (
<li key={ingredient.id}>{ingredient.original}</li>
))}
</ul>
<h2>Instructions:</h2>
<div dangerouslySetInnerHTML={{ __html: recipe.instructions }} />
</div>
</div>
);
}
Let’s break down this code:
- `useRouter`: We use the `useRouter` hook to get access to the route parameters.
- `id`: We extract the `id` from the router’s query parameters. This `id` corresponds to the recipe ID.
- `useEffect`: We use the `useEffect` hook to fetch the recipe data when the component mounts and the `id` changes.
- API Call: The code calls the Spoonacular API to fetch the recipe details based on the `id`. Make sure to replace the placeholder API key with your actual key.
- Error Handling and Loading States: The code includes error handling and loading states.
- Conditional Rendering: The code renders the recipe details if the data is available.
- `dangerouslySetInnerHTML`: The `dangerouslySetInnerHTML` prop is used to render the HTML instructions from the API. Be cautious when using this, as it can be a security risk if the content is not sanitized. In this case, the Spoonacular API provides the content, so it is assumed to be safe.
Create a file named `styles/RecipeDetail.module.css` and add the following CSS:
.container {
max-width: 800px;
margin: 2rem auto;
padding: 1rem;
border: 1px solid #ccc;
border-radius: 5px;
}
.recipeImage {
max-width: 100%;
height: auto;
margin-bottom: 1rem;
}
.recipeInfo {
margin-top: 1rem;
}
.recipeInfo h2 {
margin-bottom: 0.5rem;
}
.error {
color: red;
}
Import the CSS file into `pages/recipe/[id].js` by adding this line at the top of the file:
import styles from '../../styles/RecipeDetail.module.css';
Your `pages/recipe/[id].js` file should now look like this:
import { useRouter } from 'next/router';
import { useState, useEffect } from 'react';
import styles from '../../styles/RecipeDetail.module.css';
export default function RecipeDetail() {
const router = useRouter();
const { id } = router.query;
const [recipe, setRecipe] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
if (!id) return; // Prevent fetching before the id is available
const fetchRecipe = async () => {
setLoading(true);
setError(null);
try {
const apiKey = process.env.RECIPE_API_KEY; // Use your API key
const apiUrl = `https://api.spoonacular.com/recipes/${id}/information?apiKey=${apiKey}`;
const response = await fetch(apiUrl);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
setRecipe(data);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
fetchRecipe();
}, [id]);
if (loading) return <p>Loading...</p>;
if (error) return <p className={styles.error}>Error: {error}</p>;
if (!recipe) return <p>Recipe not found.</p>;
return (
<div className={styles.container}>
<h1>{recipe.title}</h1>
<img src={recipe.image} alt={recipe.title} className={styles.recipeImage} />
<div className={styles.recipeInfo}>
<h2>Ingredients:</h2>
<ul>
{recipe.extendedIngredients.map((ingredient) => (
<li key={ingredient.id}>{ingredient.original}</li>
))}
</ul>
<h2>Instructions:</h2>
<div dangerouslySetInnerHTML={{ __html: recipe.instructions }} />
</div>
</div>
);
}
6. Testing the Application
Now, let’s test the application. Open your browser and go to `http://localhost:3000`. Enter your ingredients in the input field, separated by commas (e.g., chicken, rice, vegetables). Click the “Find Recipes” button. You should see a list of recipes based on your input. Click on a recipe title to view its details. If everything is set up correctly, you should see the recipe title, image, ingredients, and instructions.
7. Handling Errors and Edge Cases
While the application is functional, it’s essential to consider error handling and edge cases. Here are some improvements you can make:
- API Key Handling: As mentioned earlier, store your API key securely in a `.env.local` file and access it using `process.env.RECIPE_API_KEY`.
- Input Validation: Add input validation to the ingredient input field to prevent empty searches or invalid input formats.
- Rate Limiting: If the API you are using has rate limits, implement a mechanism to handle them gracefully (e.g., display a message to the user).
- User Experience: Improve the user experience with better loading indicators, error messages, and visual feedback.
8. Deploying Your Next.js Application
Once you’re satisfied with your application, you can deploy it to a platform like Vercel, Netlify, or AWS. These platforms offer easy deployment for Next.js applications. Here’s a basic overview of deploying to Vercel:
- Push your code to a Git repository: (e.g., GitHub, GitLab, Bitbucket).
- Sign up for a Vercel account: If you don’t already have one, go to https://vercel.com/ and sign up.
- Import your Git repository: In your Vercel dashboard, click “Import Project” and select your Git repository.
- Configure your project: Vercel will automatically detect that it’s a Next.js project. You may need to configure environment variables (like your API key) in the Vercel dashboard.
- Deploy: Click “Deploy” and Vercel will build and deploy your application. Vercel will provide you with a URL where your application will be live.
Key Takeaways
- Next.js provides a powerful framework for building modern web applications. It offers features like server-side rendering, static site generation, and API routes.
- API routes simplify backend development. You can create API endpoints directly within your Next.js application.
- Component composition promotes code reusability and maintainability. Break down your application into smaller, reusable components.
- Error handling and user experience are crucial for a polished application. Always consider how your application will handle errors and provide feedback to the user.
- This project is a starting point. You can expand it with features like user authentication, recipe saving, and more.
Common Mistakes and How to Fix Them
- Incorrect API Key: Double-check your API key and make sure it’s correct. Also, ensure that the API key is accessible in your environment (e.g., `.env.local` and Vercel configuration).
- CORS Errors: If you encounter CORS errors, make sure that your API allows requests from your domain. If you’re using a proxy, ensure it is configured correctly.
- Typos in Code: Carefully review your code for typos, especially in variable names, function names, and API endpoints.
- Missing Dependencies: Make sure you have installed all the necessary dependencies using `npm install` or `yarn install`.
- Incorrect File Paths: Verify that you have the correct file paths for importing CSS files and other modules.
- Not Using `async/await` Correctly: When working with asynchronous operations (like `fetch`), make sure to use `async/await` correctly to handle promises.
FAQ
1. How can I add more features to this application?
You can add features like:
- User authentication and account management.
- Recipe saving and favorites.
- Recipe search filtering and sorting.
- Integration with more recipe APIs.
- A more advanced user interface.
2. Where can I find a free recipe API?
The Spoonacular API used in this example offers a free tier. Other options include:
- Edamam Recipe Search API (free tier).
- TheMealDB (free, but limited).
- Consider using a proxy if needed to avoid CORS issues.
3. How do I handle rate limits from the API?
If the API has rate limits, implement a mechanism to handle them. This might include:
- Displaying a message to the user when the rate limit is exceeded.
- Implementing a queue to process requests.
- Using a caching mechanism to store API responses.
4. How do I style my application?
You can style your application using:
- CSS Modules: (as shown in this tutorial) – CSS Modules provide a way to scope your CSS styles to specific components, preventing naming conflicts.
- CSS-in-JS libraries: (e.g., Styled Components, Emotion) – These libraries allow you to write CSS directly in your JavaScript code.
- Traditional CSS files: You can also use regular CSS files, but be careful of potential naming conflicts.
5. How can I make my application more accessible?
To improve accessibility, consider these points:
- Use semantic HTML elements (e.g., `<nav>`, `<article>`, `<aside>`).
- Provide alt text for all images.
- Ensure sufficient color contrast.
- Use ARIA attributes to improve the accessibility of interactive elements.
- Make your application keyboard navigable.
This guide provides a solid foundation for building a recipe ingredient finder. By following these steps and exploring the additional features, you’ll not only create a useful tool but also deepen your understanding of Next.js and web development principles. Remember that the journey of building a web application is iterative; don’t hesitate to experiment, learn from your mistakes, and continually refine your code. Embrace the process of learning and building, and watch your skills grow with each project you undertake. The possibilities are endless, and with each line of code, you’re building not just an application, but also your own expertise. The world of web development is ever-evolving, and by staying curious and dedicated, you’ll be well-equipped to navigate its exciting landscape.
