Meal Prep Recipe Card Builder
Build an interactive recipe card app with Next.js, TypeScript, and Tailwind CSS.
Introduction
30 Second Summary
Ever opened a recipe app and wondered how it tracks ingredients and calories in real time? That same logic powers the recipe management modules inside enterprise software.
In this project, you will build a meal prep recipe card builder using Next.js, TypeScript, and Tailwind CSS. You will create interactive recipe cards, add ingredients with calorie counts, and watch totals update live as you type.
What You'll Build
Picture opening your finished app, clicking "+ New Recipe," and watching a fresh card appear on screen. You type in a recipe name, add ingredients one by one, and the calorie counter climbs with each addition.
By the end of this project, you'll have:
- An interactive recipe card where you type a recipe name, set servings, and add or remove ingredients while calorie totals recalculate instantly.
- A multi-file component structure where each piece of your UI lives in its own focused file and communicates through typed props, just like a real production app.
- A "+ New Recipe" button that spawns additional independent cards on a responsive grid. Each card tracks its own ingredients and totals separately.
- Secret Mission: Add page navigation with Next.js routing so you can click between your builder and a sample recipes gallery without a full page reload.
Do I need anything installed?
This project assumes you already have Node.js and VS Code on your machine. Everything else is included when you scaffold the project in Step 1.
Before We Start
Before diving into code, let's get clear on what you're building and why it matters. Understanding the end goal now helps every step click into place.
Scaffold Your Next.js Project
Before you can build a recipe card, you need a working development environment. This means having the right frameworks installed and configured so you can write code, see changes live, and catch errors early.
Next.js has a scaffold tool called create-next-app that sets up React, TypeScript, and Tailwind CSS in a single command. You will run that command, then start a development server to confirm everything works.
In this step, get ready to:
- Scaffold a new Next.js project with TypeScript and Tailwind CSS.
- Start the development server and see the default starter page in your browser.
Create a new Next.js project
- 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
- Scaffold your Next.js project by running this command. It is interactive and will ask you two questions before it starts.
npx create-next-app@latest
- When prompted for the project name, type recipe-cards and press Enter.
- When prompted for configuration, select Yes, use recommended defaults and press Enter. This enables TypeScript, ESLint, Tailwind CSS, and the App Router.
The command downloads and installs all the project dependencies. This may take a minute depending on your internet connection.
- Once installation finishes, confirm the project folder was created by running:
ls recipe-cards
You should see a list of files and folders including app, node_modules, package.json, and tsconfig.json.
What did create-next-app set up?
That single command created an entire project with everything wired together:
- The app/ folder holds your pages and layouts. This is the App Router, which maps folders to URL paths.
- TypeScript is configured so you get type checking and autocomplete as you code.
- Tailwind CSS is installed and ready to use for styling with utility classes.
Seeing an error during scaffolding?
- If you see a "command not found" error for npx, your Node.js installation may not be in your system PATH. Close and reopen your terminal, then try again.
- If the install hangs or fails with network errors, check your internet connection and retry the command.
Help me troubleshoot my create-next-app scaffold error.
Open the project in VS Code
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type VS Code and press Enter to open it.
- Click File in the top menu bar.
- Click Open Folder.
- Navigate to your Desktop, select the recipe-cards folder, and click Open.
You should see the project files in the Explorer sidebar on the left, including the app folder, package.json, and tsconfig.json.
Start the development server
- Open the built-in terminal in VS Code by pressing Ctrl+` (the backtick key, next to the 1 key on your keyboard). This works on both macOS and Windows.
- Start the development server by running this command:
npm run dev
What does this command do?
This starts a local development server that watches your files for changes. While the server is running, it takes over this terminal panel. You will not be able to type other commands in this terminal until you stop the server with Ctrl+C.
Before you check the browser, what do you think you will see at http://localhost:3000? You have not written any code yet, only ran the scaffold command.
- Open your browser and navigate to http://localhost:3000.
You should see the default Next.js starter page. This page comes from the scaffold template and proves that your project is set up correctly and the dev server is running.
Don't see the starter page?
- Make sure you typed http://localhost:3000 in the browser address bar, not in a search engine.
- Check that the terminal still shows the dev server running. If it exited with an error, try running npm run dev again.
- If port 3000 is already in use, the terminal will show an error. Close other applications that might be using that port and retry.
Help me get my Next.js dev server running at localhost:3000.
Your development environment is ready. Next up, you will replace that starter page with your own recipe card and learn how React components and Tailwind CSS work together.
Build a Static Recipe Card
Your Next.js project is scaffolded and the dev server is running. The default starter page proves the tooling works, but it is not your app yet.
Before making anything interactive, you need a visible starting point. In this step, you will put a styled recipe card on screen using hardcoded data to learn two core patterns: JSX (how React describes UI) and Tailwind CSS (how you style it).
In this step, get ready to:
- Replace the default styles with a clean Tailwind CSS import.
- Build a recipe card component with hardcoded ingredients and calorie data.
- Experiment with Tailwind utility classes to see how styling works.
Clear the default styles
The scaffold created an app/globals.css file packed with default styling. You need to clear it so Tailwind CSS starts from a clean slate.
- In VS Code, click on app/globals.css in the Explorer sidebar on the left.
- Select all the content with Cmd+A (macOS) or Ctrl+A (Windows), then delete it.
- Replace the file contents with this single line:
@import "tailwindcss";
What does this line do?
This single import loads all of Tailwind CSS into your project. It replaces the old three-directive pattern (@tailwind base, etc.) used in older versions of Tailwind.
By clearing the rest of the file, you remove the scaffold's default styles so every style you see from now on is one you wrote.
- Save app/globals.css with Cmd+S (macOS) or Ctrl+S (Windows).
Build the recipe card component
Now you will replace the default page with a React component that renders a styled recipe card. The card uses hardcoded data so you can focus on how the UI is structured.
- Click on app/page.tsx in the Explorer sidebar.
- Select all the content and delete it, just like before.
- Replace the file with this recipe card component:
// Recipe card with hardcoded ingredients and calorie totals
export default function Home() {
return (
<main className="min-h-screen bg-gray-50 p-8">
<h1 className="text-3xl font-bold text-gray-900 mb-8">Recipe Card Builder</h1>
<div className="bg-white rounded-xl shadow-md p-6 max-w-md">
<h2 className="text-xl font-semibold text-gray-800 mb-2">Chicken Salad</h2>
<p className="text-sm text-gray-500 mb-4">Servings: 2</p>
<h3 className="text-lg font-medium text-gray-700 mb-2">Ingredients</h3>
<ul className="space-y-2 mb-4">
<li className="flex justify-between items-center text-gray-600">
<span>Chicken Breast - 200g</span>
<span>330 cal</span>
</li>
<li className="flex justify-between items-center text-gray-600">
<span>Romaine Lettuce - 100g</span>
<span>17 cal</span>
</li>
<li className="flex justify-between items-center text-gray-600">
<span>Olive Oil - 15ml</span>
<span>118 cal</span>
</li>
</ul>
<div className="pt-4 border-t border-gray-200">
<p className="text-lg font-bold text-gray-900">Total: 465 calories</p>
<p className="text-sm text-gray-500">Per serving: 233 cal</p>
</div>
</div>
</main>
);
}
What does this code do?
- export default function Home() defines a React component. A component is a function that returns what the UI should look like.
- The HTML-like syntax inside return() is JSX. It looks like HTML but compiles to JavaScript. That is how React describes UI.
- The className strings are Tailwind CSS utility classes. Each class does one thing. For example, text-3xl sets font size, font-bold sets weight, and bg-white sets the background.
- flex justify-between on each <li> pushes the ingredient name to the left and the calorie count to the right.
Before you check the browser, what do you think the page will look like? Picture the layout based on the classes you just read.
- Save app/page.tsx.
- Switch to your browser and check http://localhost:3000. You should see a styled recipe card with the title "Chicken Salad", three ingredients listed with their calorie counts, and a total of 465 calories at the bottom.
Don't see the recipe card?
- Make sure you saved both app/globals.css and app/page.tsx. Unsaved files show a dot next to the filename in the VS Code tab.
- Check that your dev server is still running in the terminal. If it stopped, run npm run dev again.
- If you see a blank page or error, double check that the curly braces and parentheses match. A missing closing ) or } will break the component.
Help me debug why my recipe card is not showing up.
✔️ Awesome, I've got everything!
Great. Make sure both files are saved before moving on.
ⓧ I'd like to double check the full code
Here is app/globals.css in full:
@import "tailwindcss";
Here is app/page.tsx in full:
export default function Home() {
return (
<main className="min-h-screen bg-gray-50 p-8">
<h1 className="text-3xl font-bold text-gray-900 mb-8">Recipe Card Builder</h1>
<div className="bg-white rounded-xl shadow-md p-6 max-w-md">
<h2 className="text-xl font-semibold text-gray-800 mb-2">Chicken Salad</h2>
<p className="text-sm text-gray-500 mb-4">Servings: 2</p>
<h3 className="text-lg font-medium text-gray-700 mb-2">Ingredients</h3>
<ul className="space-y-2 mb-4">
<li className="flex justify-between items-center text-gray-600">
<span>Chicken Breast - 200g</span>
<span>330 cal</span>
</li>
<li className="flex justify-between items-center text-gray-600">
<span>Romaine Lettuce - 100g</span>
<span>17 cal</span>
</li>
<li className="flex justify-between items-center text-gray-600">
<span>Olive Oil - 15ml</span>
<span>118 cal</span>
</li>
</ul>
<div className="pt-4 border-t border-gray-200">
<p className="text-lg font-bold text-gray-900">Total: 465 calories</p>
<p className="text-sm text-gray-500">Per serving: 233 cal</p>
</div>
</div>
</main>
);
}
Understand your component and Tailwind classes
You just wrote three things that power every React app. Take a moment to name them.
What is a React component?
The Home function you wrote is a React component. A component is a JavaScript function that returns JSX describing a piece of UI. Every page, button, and form in a React app is a component.
What is JSX?
JSX is the HTML-like syntax inside return(). It looks like HTML but compiles to JavaScript function calls. You use it to describe what the browser should render.
One key difference from HTML: JSX uses className instead of class because class is a reserved word in JavaScript.
How do Tailwind CSS utility classes work?
Each Tailwind class applies one specific CSS property. You compose them together to build up a complete style. There is no separate CSS file to manage.
- bg-white sets the background to white.
- rounded-xl rounds the corners.
- shadow-md adds a medium drop shadow.
- p-6 adds padding on all sides.
- mb-8 adds margin below the element.
Experiment with Tailwind utility classes
Tailwind is best understood by trying it. Change a few classes and watch the card update in real time.
- In app/page.tsx, find the card's <div> and change bg-white to bg-blue-50.
- Save the file and check the browser. The card background changes to a light blue.
- Try changing text-xl on the recipe title to text-2xl and save again. The title gets larger.
This is how Tailwind works. You compose small, single-purpose classes instead of writing separate CSS files. Every style is visible right in the markup.
- Set bg-blue-50 back to bg-white and text-2xl back to text-xl so the card matches the original code.
- Save app/page.tsx and confirm the card looks the same as before: a white card with "Chicken Salad", three ingredients, and a 465-calorie total.
Tailwind's class reference
Tailwind has thousands of utility classes. You do not need to memorize them. Search for any property at tailwindcss.com/docs and you will find the matching class name in seconds.
Your recipe card looks great, but it is completely frozen. You cannot add new ingredients or change the recipe name because every value is hardcoded. Next up, you will bring it to life with React state so the card responds to what you type.
Make It Interactive with State
In the last step, you built a styled recipe card with Tailwind CSS and saw it appear in the browser.
The card looks great, but it is frozen. You cannot add or remove ingredients because the data is hardcoded directly in the JSX. React's useState hook lets you store data that can change, and the UI automatically re-renders when it does.
In this step, get ready to:
- Define a TypeScript interface for ingredients and add state variables that React can track.
- Build an Add Ingredient form and wire it to state so ingredients appear dynamically.
- See calorie totals update live as you add and remove ingredients.
Add the client directive, state, and TypeScript types
React components are "server components" by default in Next.js. To use interactive features like useState, you need to mark the file as a client component.
- In VS Code, open app/page.tsx.
- Select all the code in the file and delete it.
- Paste in the following code, which adds the client directive, imports useState, and defines the shape of an ingredient using a TypeScript interface:
"use client";
import { useState } from "react";
// Define the shape of an ingredient
interface Ingredient {
id: number;
name: string;
quantity: number;
unit: string;
calories: number;
}
What does this code do?
"use client" tells Next.js this component runs in the browser, not on the server. Without it, React hooks like useState will throw an error.
The Ingredient interface is a TypeScript contract. It tells the compiler exactly what fields every ingredient must have, so you get autocomplete and error checking as you type.
- Directly below the interface, paste the component function that sets up state and renders an editable card:
export default function Home() {
// State for recipe details
const [recipeName, setRecipeName] = useState("My Recipe");
const [servings, setServings] = useState(2);
const [ingredients, setIngredients] = useState<Ingredient[]>([]);
// Calorie calculations that update automatically
const totalCalories = ingredients.reduce((sum, ing) => sum + ing.calories, 0);
const caloriesPerServing = servings > 0 ? Math.round(totalCalories / servings) : 0;
return (
<main className="min-h-screen bg-gray-50 p-8">
<h1 className="text-3xl font-bold text-gray-900 mb-8">Recipe Card Builder</h1>
<div className="bg-white rounded-xl shadow-md p-6 max-w-md">
<input type="text" value={recipeName} onChange={(e) => setRecipeName(e.target.value)} className="text-xl font-semibold text-gray-800 mb-2 w-full bg-transparent border-b border-gray-300 focus:border-blue-500 focus:outline-none" />
<label className="block text-sm text-gray-500 mb-4">Servings: <input type="number" value={servings} onChange={(e) => setServings(Number(e.target.value))} min={1} className="ml-2 w-16 border border-gray-300 rounded px-2 py-1 text-gray-700" /></label>
<h3 className="text-lg font-medium text-gray-700 mb-2">Ingredients</h3>
<p className="text-gray-400 italic mb-4">No ingredients yet. Add one below!</p>
<div className="pt-4 border-t border-gray-200">
<p className="text-lg font-bold text-gray-900">Total: {totalCalories} calories</p>
<p className="text-sm text-gray-500">Per serving: {caloriesPerServing} cal</p>
</div>
</div>
</main>
);
}
What's happening here?
- useState("My Recipe") creates a state variable with a default value. When you call the setter (like setRecipeName), React re-renders the component with the new value.
- The <input> elements use value and onChange to create a two-way binding. The state drives what the input displays, and typing updates the state.
- totalCalories and caloriesPerServing are computed values that recalculate every time the ingredients array changes.
- Save app/page.tsx.
- Switch to your browser. The card now shows an editable text field where "Chicken Salad" used to be, a number input for servings, and "No ingredients yet." where the hardcoded list was. The totals show 0 calories.
- Click the recipe name field and type a new name. The text updates as you type.
Seeing a blank page or an error?
- Check that "use client"; is the very first line of the file, before the import.
- Make sure you deleted all the old hardcoded code. The file should only contain the two blocks you just pasted.
- Look at the terminal running your dev server for a specific error message. A typo in the interface or a missing closing brace will show up there.
Why is my recipe card showing a blank page after adding useState?
What is useState?
useState is a React hook that gives your component memory. Regular JavaScript variables reset every time a component re-renders. State variables persist across renders and trigger a re-render when they change.
The pattern is always const [value, setValue] = useState(defaultValue). You read from value and write with setValue.
Build the ingredient functions and form
The card is editable now, but you still cannot add ingredients. You need state variables for the form inputs, a function to add a new ingredient to the array, and a function to remove one.
- In app/page.tsx, find this line:
const totalCalories = ingredients.reduce((sum, ing) => sum + ing.calories, 0);
- Directly above that line, add the form state variables, then add the two functions below the caloriesPerServing line. Paste this code between the ingredients state line and the totalCalories line:
// State for the form inputs
const [name, setName] = useState("");
const [quantity, setQuantity] = useState("");
const [unit, setUnit] = useState("g");
const [calories, setCalories] = useState("");
- Now find the caloriesPerServing line. Directly below it, paste the two functions that add and remove ingredients:
// Add a new ingredient to the array
function addIngredient() {
if (!name || !quantity || !calories) return;
setIngredients([...ingredients, { id: Date.now(), name, quantity: Number(quantity), unit, calories: Number(calories) }]);
setName(""); setQuantity(""); setUnit("g"); setCalories("");
}
// Remove an ingredient by its id
function removeIngredient(id: number) {
setIngredients(ingredients.filter((ing) => ing.id !== id));
}
What do these functions do?
- addIngredient first checks that the required fields are not empty. Then it creates a new ingredient object with a unique id from Date.now() and appends it to the array using the spread operator.
- removeIngredient uses filter() to create a new array without the ingredient that matches the given id. React sees a new array reference and re-renders.
- After adding, the form fields reset to empty strings so the learner can immediately type another ingredient.
Replace the hardcoded list with a dynamic ingredient list and form
The functions are ready, but the JSX still shows the static placeholder. You need to replace it with a dynamic list that renders from the ingredients array and add the form inputs.
- In app/page.tsx, find this line inside the return statement:
<p className="text-gray-400 italic mb-4">No ingredients yet. Add one below!</p>
- Replace that single line with the following code, which renders the ingredient list dynamically and adds the form:
{/* Show placeholder or dynamic ingredient list */}
{ingredients.length === 0 ? <p className="text-gray-400 italic mb-4">No ingredients yet. Add one below!</p> : (
<ul className="space-y-2 mb-4">
{ingredients.map((ing) => (
<li key={ing.id} className="flex justify-between items-center text-gray-600">
<span>{ing.name} - {ing.quantity}{ing.unit}</span>
<div className="flex items-center gap-2"><span>{ing.calories} cal</span><button onClick={() => removeIngredient(ing.id)} className="text-red-400 hover:text-red-600 text-sm">x</button></div>
</li>
))}
</ul>
)}
What does this code do?
- The ternary (? :) checks if the array is empty. If it is, it shows the placeholder message. Otherwise, it maps over each ingredient and renders a list item.
- ingredients.map() loops through every ingredient and produces a <li> for each one. The key={ing.id} prop helps React track which items changed so it can update efficiently.
- Each ingredient row has an "x" button that calls removeIngredient(ing.id) when clicked.
- Directly below the code you just pasted (before the <div className="pt-4 border-t line), add the form that lets users input new ingredients:
{/* Add Ingredient form */}
<div className="bg-gray-50 rounded-lg p-4 mb-4">
<h4 className="text-sm font-medium text-gray-700 mb-2">Add Ingredient</h4>
<div className="grid grid-cols-2 gap-2">
<input type="text" placeholder="Name" value={name} onChange={(e) => setName(e.target.value)} className="border border-gray-300 rounded px-2 py-1 text-sm" />
<input type="number" placeholder="Qty" value={quantity} onChange={(e) => setQuantity(e.target.value)} className="border border-gray-300 rounded px-2 py-1 text-sm" />
<select value={unit} onChange={(e) => setUnit(e.target.value)} className="border border-gray-300 rounded px-2 py-1 text-sm"><option value="g">g</option><option value="ml">ml</option><option value="pcs">pcs</option><option value="tbsp">tbsp</option><option value="tsp">tsp</option></select>
<input type="number" placeholder="Calories" value={calories} onChange={(e) => setCalories(e.target.value)} className="border border-gray-300 rounded px-2 py-1 text-sm" />
</div>
<button onClick={addIngredient} className="mt-2 w-full bg-blue-500 text-white rounded py-1 text-sm hover:bg-blue-600">Add Ingredient</button>
</div>
What does this form do?
Each <input> is wired to its own state variable with value and onChange. When you type, React updates the state and re-renders the input with the new value. The <select> works the same way for the unit dropdown.
The button calls addIngredient on click, which reads the current form state, constructs a new ingredient object, and appends it to the array.
- Save app/page.tsx.
Before you check the browser, what do you think will happen when you type "Rice" in the Name field, "150" in Qty, select "g", enter "200" for Calories, and click Add Ingredient?
- Switch to your browser and try it. Type Rice in the Name field, 150 in Qty, leave the unit as g, enter 200 for Calories, and click Add Ingredient.
"Rice - 150g" appears in the ingredient list, the total shows 200 calories, and the form fields clear so you can add another. Click the "x" next to Rice and it disappears. The total drops back to 0.
Ingredients not appearing when you click Add?
- Make sure all three required fields (Name, Qty, Calories) have values. The addIngredient function silently returns if any of them are empty.
- Check that the form state variables (name, quantity, calories) are declared above the totalCalories line, not below the return statement.
- Verify the button's onClick says onClick={addIngredient} with no parentheses after the function name. Parentheses would call it immediately instead of on click.
Why are ingredients not appearing when I click Add Ingredient in my React form?
✔️ Awesome, I've got everything!
Great. Double check you have saved app/page.tsx and that adding and removing ingredients works in the browser.
ⓧ I'd like to double check the full code
Your app/page.tsx should contain these sections in order. Scroll up and confirm each is present:
- "use client"; directive and import { useState } from "react"; at the top.
- The Ingredient interface with five fields: id, name, quantity, unit, calories.
- Seven useState calls inside Home(): recipeName, servings, ingredients, name, quantity, unit, calories.
- Computed values totalCalories and caloriesPerServing.
- addIngredient() and removeIngredient() functions.
- The return JSX with an editable name input, servings input, conditional ingredient list, Add Ingredient form (four inputs and a button), and calorie totals.
The app/globals.css file is unchanged. It should still contain just @import "tailwindcss";.
Your recipe card is fully interactive. You can name a recipe, set servings, add ingredients, remove them, and see calorie totals recalculate in real time. Next up, you will break this single file into reusable components so the code is clean and organized.
Extract Components and Props
Your recipe card works. You can add ingredients, remove them, and watch calorie totals update in real time. But the entire app lives in a single file that is already getting long.
Real apps split their UI into small, focused components that each handle one job. In this step, you will extract your code into separate component files and learn how they communicate using props. This is the same pattern used throughout production Next.js apps.
In this step, get ready to:
- Create a shared TypeScript type definition for ingredients.
- Extract the ingredient list and form into their own component files with typed props.
- Compose the components inside a RecipeCard and simplify the page to about 10 lines.
Create a shared type definition
The Ingredient interface currently lives inside page.tsx. Multiple components will need this type, so it belongs in its own file where any component can import it.
- In the VS Code Explorer sidebar, right-click the project root (recipe-cards) and select New Folder.
- Name the folder types.
- Right-click the types folder and select New File.
- Name the file recipe.ts.
- Paste the following code into types/recipe.ts:
export interface Ingredient {
id: number;
name: string;
quantity: number;
unit: string;
calories: number;
}
What does this code do?
This is the same Ingredient interface you wrote in page.tsx, but now it is exported with the export keyword so other files can import it.
A TypeScript interface defines the shape of your data. Any component that imports Ingredient gets compile-time checking that it is using the right field names and types.
- Save types/recipe.ts.
- Confirm the file exists by running:
ls types/
- You should see recipe.ts in the output.
Build the ingredient list component
The ingredient list display logic in page.tsx will become its own component. Instead of owning the data, it will receive everything it needs through props.
- Create a components folder in the project root (same level as types) by right-clicking in the Explorer sidebar and selecting New Folder.
- Create a new file inside components called IngredientList.tsx.
- Paste the following code into components/IngredientList.tsx:
import { Ingredient } from "@/types/recipe";
// Define what data this component expects to receive
interface IngredientListProps {
ingredients: Ingredient[];
onRemove: (id: number) => void;
}
export default function IngredientList({ ingredients, onRemove }: IngredientListProps) {
// Show a placeholder message when the list is empty
if (ingredients.length === 0) {
return <p className="text-gray-400 italic mb-4">No ingredients yet. Add one below!</p>;
}
return (
<ul className="space-y-2 mb-4">
{ingredients.map((ing) => (
<li key={ing.id} className="flex justify-between items-center text-gray-600">
<span>
{ing.name} - {ing.quantity}{ing.unit}
</span>
<div className="flex items-center gap-2">
<span>{ing.calories} cal</span>
<button
onClick={() => onRemove(ing.id)}
className="text-red-400 hover:text-red-600 text-sm"
>
x
</button>
</div>
</li>
))}
</ul>
);
}
What are props?
Props are data that a parent component passes down to a child component. The IngredientListProps interface defines exactly what this component expects to receive: an ingredients array and an onRemove callback function.
This component does not own any data. It just displays what it receives and calls onRemove when the user clicks the delete button. The parent decides what actually happens.
- Save components/IngredientList.tsx.
- Confirm the file exists by running:
ls components/
- You should see IngredientList.tsx in the output.
✔️ Awesome, I've got everything!
Great. Make sure you have saved the file before moving on.
ⓧ I'd like to double check the full code
Here is the full components/IngredientList.tsx for comparison:
import { Ingredient } from "@/types/recipe";
interface IngredientListProps {
ingredients: Ingredient[];
onRemove: (id: number) => void;
}
export default function IngredientList({ ingredients, onRemove }: IngredientListProps) {
if (ingredients.length === 0) {
return <p className="text-gray-400 italic mb-4">No ingredients yet. Add one below!</p>;
}
return (
<ul className="space-y-2 mb-4">
{ingredients.map((ing) => (
<li key={ing.id} className="flex justify-between items-center text-gray-600">
<span>
{ing.name} - {ing.quantity}{ing.unit}
</span>
<div className="flex items-center gap-2">
<span>{ing.calories} cal</span>
<button
onClick={() => onRemove(ing.id)}
className="text-red-400 hover:text-red-600 text-sm"
>
x
</button>
</div>
</li>
))}
</ul>
);
}
Seeing a red underline on the import?
Make sure your types/recipe.ts file is saved and that the import path is @/types/recipe (with the @/ prefix, not a relative path).
help me fix the import error in IngredientList.tsx
Build the ingredient form component
The form logic will also move to its own component. Unlike IngredientList, the form manages its own internal state for the input fields. It only calls the parent when the user clicks the submit button.
- Create a new file inside the components folder called IngredientForm.tsx.
- Start by pasting the imports, props interface, and state hooks:
"use client";
import { useState } from "react";
import { Ingredient } from "@/types/recipe";
// Define what callback this component expects from its parent
interface IngredientFormProps {
onAdd: (ingredient: Ingredient) => void;
}
export default function IngredientForm({ onAdd }: IngredientFormProps) {
// This component manages its own form input state
const [name, setName] = useState("");
const [quantity, setQuantity] = useState("");
const [unit, setUnit] = useState("g");
const [calories, setCalories] = useState("");
Why does IngredientForm need its own state?
The form inputs (name, quantity, unit, calories) are temporary. They only matter while the user is typing. Once the user clicks "Add Ingredient", the form resets. That makes them a perfect fit for local state inside this component, rather than being passed down from a parent.
- Below the state hooks and still inside the component function, add the submit handler:
function handleSubmit() {
if (!name || !quantity || !calories) return;
onAdd({
id: Date.now(),
name,
quantity: Number(quantity),
unit,
calories: Number(calories),
});
setName("");
setQuantity("");
setUnit("g");
setCalories("");
}
What does handleSubmit do?
This is the same logic as your addIngredient function from page.tsx, but now it calls onAdd (the callback prop from the parent) instead of calling setIngredients directly. The parent decides how to store the ingredient. The form just builds it and hands it over.
- Below the handleSubmit function and still inside the component, add the return statement with the form JSX:
return (
<div className="bg-gray-50 rounded-lg p-4 mb-4">
<h4 className="text-sm font-medium text-gray-700 mb-2">Add Ingredient</h4>
<div className="grid grid-cols-2 gap-2">
<input type="text" placeholder="Name" value={name} onChange={(e) => setName(e.target.value)} className="border border-gray-300 rounded px-2 py-1 text-sm" />
<input type="number" placeholder="Qty" value={quantity} onChange={(e) => setQuantity(e.target.value)} className="border border-gray-300 rounded px-2 py-1 text-sm" />
<select value={unit} onChange={(e) => setUnit(e.target.value)} className="border border-gray-300 rounded px-2 py-1 text-sm">
<option value="g">g</option>
<option value="ml">ml</option>
<option value="pcs">pcs</option>
<option value="tbsp">tbsp</option>
<option value="tsp">tsp</option>
</select>
<input type="number" placeholder="Calories" value={calories} onChange={(e) => setCalories(e.target.value)} className="border border-gray-300 rounded px-2 py-1 text-sm" />
</div>
<button onClick={handleSubmit} className="mt-2 w-full bg-blue-500 text-white rounded py-1 text-sm hover:bg-blue-600">Add Ingredient</button>
</div>
);
}
What's happening here?
This is the exact same form JSX you already have in page.tsx. The only difference is the button now calls handleSubmit (which uses the onAdd prop) instead of calling addIngredient directly.
- Save components/IngredientForm.tsx.
- Confirm both component files exist by running:
ls components/
- You should see both IngredientForm.tsx and IngredientList.tsx in the output.
✔️ Awesome, I've got everything!
Great. Make sure both files are saved before moving on.
ⓧ I'd like to double check the full code
The full components/IngredientForm.tsx is over 30 lines, so scroll up and confirm your file contains all three sections in order:
- The "use client" directive, imports, and IngredientFormProps interface at the top.
- The handleSubmit function with the guard clause, onAdd call, and state resets.
- The return statement with the form inputs, select dropdown, and submit button.
Seeing errors in the file?
Make sure the closing brace } at the very end of the file closes the IngredientForm function. Missing or extra braces cause syntax errors that can be hard to spot.
Also check that "use client" is on the very first line. It must be at the top of the file, before any imports.
help me fix errors in my IngredientForm component
Compose RecipeCard and simplify the page
Now you have two standalone components for the list and the form. The next piece is a RecipeCard component that brings them together and manages the recipe state. Once that exists, page.tsx shrinks from around 100 lines to about 10.
- Create a new file inside the components folder called RecipeCard.tsx.
- Paste the following code to set up the imports, state, and handler functions:
"use client";
import { useState } from "react";
import { Ingredient } from "@/types/recipe";
import IngredientList from "./IngredientList";
import IngredientForm from "./IngredientForm";
export default function RecipeCard() {
// Recipe-level state: name, servings, and the ingredients array
const [recipeName, setRecipeName] = useState("My Recipe");
const [servings, setServings] = useState(2);
const [ingredients, setIngredients] = useState<Ingredient[]>([]);
// Computed values that automatically recalculate
const totalCalories = ingredients.reduce((sum, ing) => sum + ing.calories, 0);
const caloriesPerServing = servings > 0 ? Math.round(totalCalories / servings) : 0;
function addIngredient(ingredient: Ingredient) {
setIngredients([...ingredients, ingredient]);
}
function removeIngredient(id: number) {
setIngredients(ingredients.filter((ing) => ing.id !== id));
}
What does this code do?
RecipeCard imports both IngredientList and IngredientForm. It owns all the recipe state (name, servings, ingredients) and defines the addIngredient and removeIngredient functions that it will pass down to its children as props.
This is called component composition. RecipeCard is the "brain" that holds data. IngredientList and IngredientForm are the "hands" that display it and collect input.
- Below the removeIngredient function and still inside the component, add the return statement:
return (
<div className="bg-white rounded-xl shadow-md p-6">
<input type="text" value={recipeName} onChange={(e) => setRecipeName(e.target.value)} className="text-xl font-semibold text-gray-800 mb-2 w-full bg-transparent border-b border-gray-300 focus:border-blue-500 focus:outline-none" />
<label className="block text-sm text-gray-500 mb-4">Servings: <input type="number" value={servings} onChange={(e) => setServings(Number(e.target.value))} min={1} className="ml-2 w-16 border border-gray-300 rounded px-2 py-1 text-gray-700" /></label>
<h3 className="text-lg font-medium text-gray-700 mb-2">Ingredients</h3>
<IngredientList ingredients={ingredients} onRemove={removeIngredient} />
<IngredientForm onAdd={addIngredient} />
<div className="pt-4 border-t border-gray-200">
<p className="text-lg font-bold text-gray-900">Total: {totalCalories} calories</p>
<p className="text-sm text-gray-500">Per serving: {caloriesPerServing} cal</p>
</div>
</div>
);
}
What's happening here?
Instead of inline JSX for the ingredient list and form, RecipeCard renders <IngredientList /> and <IngredientForm /> as child components. It passes data down through props: the ingredients array and onRemove go to IngredientList, and onAdd goes to IngredientForm.
- Save components/RecipeCard.tsx.
- Confirm all three component files exist by running:
ls components/
- You should see IngredientForm.tsx, IngredientList.tsx, and RecipeCard.tsx in the output.
✔️ Awesome, I've got everything!
Great. Make sure the file is saved before moving on.
ⓧ I'd like to double check the full code
The full components/RecipeCard.tsx is over 30 lines, so scroll up and confirm your file contains both sections in order:
- The "use client" directive, imports, state hooks, computed values, and the addIngredient / removeIngredient functions.
- The return statement with the recipe name input, servings label, <IngredientList />, <IngredientForm />, and the calorie totals.
The last piece is to simplify page.tsx. All the logic now lives in your components, so the page just needs to import and render RecipeCard.
- Open app/page.tsx in VS Code.
- Select all the existing code (Cmd+A on macOS, Ctrl+A on Windows) and replace it with:
import RecipeCard from "@/components/RecipeCard";
export default function Home() {
return (
<main className="min-h-screen bg-gray-50 p-8">
<h1 className="text-3xl font-bold text-gray-900 mb-8">Recipe Card Builder</h1>
<RecipeCard />
</main>
);
}
What changed?
The page went from about 100 lines to 10. It no longer needs "use client" or useState because it does not manage any state. All the interactivity lives inside RecipeCard and its children.
The <RecipeCard /> tag is how you use a component in React. It renders everything the RecipeCard function returns, just like calling a function.
- Save app/page.tsx.
✔️ Awesome, I've got everything!
Great. Make sure the file is saved before verifying in the browser.
ⓧ I'd like to double check the full code
Here is the full app/page.tsx for comparison:
import RecipeCard from "@/components/RecipeCard";
export default function Home() {
return (
<main className="min-h-screen bg-gray-50 p-8">
<h1 className="text-3xl font-bold text-gray-900 mb-8">Recipe Card Builder</h1>
<RecipeCard />
</main>
);
}
Before you check the browser, think about what you expect to see. You moved code into new files but did not change any of the logic or styling. What should the app look like?
- Switch to your browser and check http://localhost:3000.
The app looks and works identically to before. Type an ingredient name, quantity, and calories, click Add Ingredient, and it appears in the list. The calorie total updates. Click the x next to an ingredient and it disappears.
Why does the app look the same?
That is exactly the point of refactoring. You restructured the code without changing what it does. The difference is in your project: five focused files instead of one long file. Each component has a single responsibility and communicates through typed props.
Seeing a blank page or errors?
Check your terminal for error messages. The most common issues are:
- Import path typos. Make sure RecipeCard.tsx imports from "./IngredientList" and "./IngredientForm" (relative paths), while page.tsx imports from "@/components/RecipeCard" (alias path).
- Missing export. Each component file must have export default function (not just function).
- Mismatched braces. Open each component file and verify the final } closes the function.
help me fix my component imports
Your code is now organized like a real Next.js app. Each component handles one thing, communicates through typed props, and can be reused anywhere. In the next step, you will prove that reusability by rendering multiple independent recipe cards on the same page.
Render Multiple Recipe Cards
In the last step, you split a single large file into focused, reusable components that communicate through typed props. Your code is organized the way real Next.js apps are structured.
A real meal prep app does not manage just one recipe. It manages dozens, each with its own ingredients and calorie tracking. Right now, your page renders a single RecipeCard. In this step, you will make the page dynamic so you can spawn as many independent recipe cards as you want.
In this step, get ready to:
- Add state to track multiple recipe card IDs.
- Render a "+ New Recipe" button and a responsive grid of independent recipe cards.
Update the page to manage multiple cards
Right now, app/page.tsx imports and renders a single <RecipeCard />. To render multiple cards, you need to store a list of IDs in state and map over them to create one card per ID.
- In VS Code, open app/page.tsx.
- Replace the entire file contents with this code:
"use client";
import { useState } from "react";
import RecipeCard from "@/components/RecipeCard";
export default function Home() {
// Track recipe card IDs in state
const [recipeIds, setRecipeIds] = useState([1]);
// Append a unique ID to spawn a new card
function addRecipe() {
setRecipeIds([...recipeIds, Date.now()]);
}
return (
<main className="min-h-screen bg-gray-50 p-8">
{/* Header with title and add button */}
<div className="flex justify-between items-center mb-8">
<h1 className="text-3xl font-bold text-gray-900">Recipe Card Builder</h1>
<button onClick={addRecipe} className="bg-green-500 text-white rounded-lg px-4 py-2 hover:bg-green-600">
+ New Recipe
</button>
</div>
{/* Responsive grid: 1 column on mobile, 2 on wider screens */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{recipeIds.map((id) => (
<RecipeCard key={id} />
))}
</div>
</main>
);
}
What does this code do?
- "use client" tells Next.js this page needs browser interactivity (because it uses useState).
- recipeIds starts as [1], so one card appears by default.
- addRecipe appends Date.now() (a unique timestamp) to the array, which triggers a re-render with a new card.
- recipeIds.map() loops over the IDs and renders one RecipeCard per ID. The key prop tells React which card is which so it can update them efficiently.
- grid grid-cols-1 md:grid-cols-2 creates a responsive layout: one column on mobile, two columns on wider screens.
- Save app/page.tsx.
- Check your browser at http://localhost:3000. You should see the same single recipe card as before, plus a green + New Recipe button in the top right corner of the header.
Don't see the green button?
- Make sure you saved app/page.tsx. Unsaved changes will not appear in the browser.
- Check that "use client" is the very first line of the file, before any imports.
- Confirm the import { useState } from "react" line is present. Without it, the component cannot use state.
Why isn't my + New Recipe button showing up after updating page.tsx?
Verify each card is independent
The button is there, but does it actually work? Before you click it, think about what you expect to happen. Will a second card appear? Will it share the same ingredients as the first card, or start empty?
- Click the + New Recipe button.
A second recipe card appears in a grid layout, side by side with the first card.
- Add an ingredient to the first card (for example: Chicken, 200g, 330 calories).
- Add a different ingredient to the second card (for example: Rice, 150g, 200 calories).
- Click + New Recipe again to spawn a third card.
Each card tracks its own recipe name, servings, and ingredients. The first card shows Chicken at 330 calories. The second shows Rice at 200 calories. The third starts empty.
Why does each card have its own state?
Each RecipeCard component has its own useState calls. When React renders multiple instances of the same component, each one gets its own isolated state. Changing one card's ingredients does not affect any other card.
This is the same pattern your ERP uses. Every recipe, every menu, every plan is an independent component instance managing its own data.
✔️ Awesome, I've got everything!
Great. Double check you have saved app/page.tsx and you are good to go.
ⓧ I'd like to double check the full code
Here is the complete app/page.tsx file. Compare it to yours line by line.
"use client";
import { useState } from "react";
import RecipeCard from "@/components/RecipeCard";
export default function Home() {
const [recipeIds, setRecipeIds] = useState([1]);
function addRecipe() {
setRecipeIds([...recipeIds, Date.now()]);
}
return (
<main className="min-h-screen bg-gray-50 p-8">
<div className="flex justify-between items-center mb-8">
<h1 className="text-3xl font-bold text-gray-900">Recipe Card Builder</h1>
<button onClick={addRecipe} className="bg-green-500 text-white rounded-lg px-4 py-2 hover:bg-green-600">
+ New Recipe
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{recipeIds.map((id) => (
<RecipeCard key={id} />
))}
</div>
</main>
);
}
Your recipe card builder is fully functional. You can create as many independent recipe cards as you want, each tracking its own ingredients and calorie totals. Next up, the Secret Mission challenges you to add page navigation so your app has multiple routes, just like a real production app.
Secret mission
Add Page Navigation with Next.js Routing
Your recipe card builder is a single-page app right now. Real apps have multiple pages. In this secret mission, you will add a navigation bar and a second page using Next.js file-based routing, the same pattern your ERP uses to handle different sections like recipes, menus, and CRM.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally, so there are no ongoing costs.
Resources you used:
- The recipe-cards project folder on your Desktop.
- The Next.js development server running in your terminal.
- Node.js packages installed inside the node_modules folder.
Keep everything running
No action needed. Choose this if you are still actively building or plan to continue with the next project in this series.
- Your dev server stays running at http://localhost:3000.
- Your recipe-cards folder stays on your Desktop, ready for the next session.
Pause - I'll come back to this later
Shut down the running dev server to free up your terminal. Your project files stay on disk so you can pick up where you left off.
- Switch to the terminal where the dev server is running.
- Press Ctrl+C to stop the server.
- When you want to resume, open your terminal, navigate into the recipe-cards folder, and run npm run dev again.
Delete - I don't want to use this again
Remove all project files and start fresh. This permanently deletes your code and any recipes you created.
- Switch to the terminal where the dev server is running and press Ctrl+C to stop it.
- Navigate to your Desktop by running:
cd ~/Desktop
- Delete the project folder. On macOS or Linux, run:
rm -rf recipe-cards
On Windows, run:
rmdir /s /q recipe-cards
- Confirm the folder is gone by running ls (macOS/Linux) or dir (Windows) on your Desktop. You should no longer see recipe-cards listed.
Nice Work!
Nice Work!
Nice work! You just built a fully interactive meal prep recipe card builder from scratch using Next.js, TypeScript, and Tailwind CSS. Every pattern you used here is one you will see again throughout your ERP codebase.
You've learned how to:
- Built a React app with JSX for describing UI and Tailwind CSS utility classes for styling, all with TypeScript interfaces enforcing your data shapes at compile time.
- Used React's useState hook to make recipe cards interactive, with live calorie totals that recalculate automatically as you add or remove ingredients.
- Extracted reusable components that communicate through typed props, and rendered dynamic lists where each RecipeCard manages its own independent state.
- Secret Mission: Added file-based routing with the Next.js App Router and a shared layout with navigation between pages.
Ready to quiz yourself?