Build a Recipe Finder with HTML, CSS & JavaScript (API + Search) — Real-Life Web Projects #2
Craving a beginner-friendly HTML CSS JavaScript project that feels real? Let’s build a JavaScript recipe finder that searches actual meals from a free API (no key!) and shows clean, responsive cards with images, ingredients, and helpful links.
This project is perfect for beginners because you’ll learn how to:
- Fetch real recipe data from a free API (TheMealDB — no API key needed ✅).
- Display search results dynamically with JavaScript.
- Style your recipe cards with modern CSS.
- Add a few UX improvements like search status and localStorage.
By the end, you’ll have your own JavaScript recipe finder that can search for meals like pasta, chicken, or rice and instantly show recipes with images, ingredients, and links to instructions. 🍝🍗🍚
Why Build This HTML CSS JavaScript Project (Recipe Finder)?
✅ Learn API integration (fetch data from a real recipe database)
✅ Practice DOM manipulation (dynamically display search results)
✅ Build a portfolio-worthy project
✅ Use it in real life (never wonder “what’s for dinner?” again)
What You’ll Build
Our Recipe Finder app will:
✅ Let users type any ingredient or dish name.
✅ Fetch matching recipes from TheMealDB API.
✅ Show recipe cards with images, ingredients, and quick links.
✅ Remember your last search when you refresh the page.
This is part of our Real-Life Web Projects series where we learn coding by building useful, everyday tools.
Live Preview of Recipe Finder project
Files to Create
Put these three files in one folder:
index.html
style.css
script.js
Open index.html
in your browser to run it.
Step 1 — HTML (structure) for your Recipe Finder project
Full code
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Recipe Finder — Codeboid</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<main class="container">
<header class="header">
<h1>Recipe Finder 🍳</h1>
<p class="sub">Search real recipes via a free API (no key). Try: <em>pasta</em>, <em>chicken</em>,
<em>rice</em>.</p>
</header>
<form id="searchForm" class="search" role="search" aria-label="Recipe search">
<label for="q" class="sr-only">Search recipes</label>
<input id="q" name="q" type="search" placeholder="Search recipes (e.g., pasta)" autocomplete="off" />
<button class="btn primary" type="submit">Search</button>
</form>
<p id="status" class="status" aria-live="polite"></p>
<section id="results" class="grid" aria-label="Search results">
<!-- Cards render here -->
</section>
<footer class="footer">
<p>Built with ❤️ by <a href="https://codeboid.com" target="_blank" rel="noopener">Codeboid</a></p>
</footer>
</main>
<script src="script.js"></script>
</body>
</html>
Explanation:
<!DOCTYPE html>
→ tells the browser this is an HTML5 page.<main class="container">
→ wraps everything inside a neat box.<header>
→ contains the app name + short instructions.<form>
→ where the user types a search (like “pasta”).<p id="status">
→ used to display messages (e.g. “Searching…”).<section id="results">
→ will hold recipe cards (added by JS).<footer>
→ little credit at the bottom.<script src="script.js">
→ loads our JavaScript file at the end.
Why these HTML attributes matter
role="search"
andaria-label="Recipe search"
help screen readers understand the form’s purpose.aria-live="polite"
on the status line politely announces “Searching… / No results…” to assistive tech..sr-only
lets us keep visible labels for screen readers without cluttering the UI.
👉 At this point, our page is just a skeleton. Nothing works yet.
Step 2 — CSS (look)
Full code
:root {
--teal-1: #0EB4A5;
--teal-2: #00123A;
--bg: #0D1117;
--card: #0B1426;
--line: #1F2937;
--fg: #E2E8F0;
--muted: #94A3B8;
--accent: linear-gradient(135deg, var(--teal-1), #0A7C77);
--radius: 16px;
--shadow: 0 10px 30px rgba(0, 0, 0, .35);
}
* {
box-sizing: border-box
}
html,
body {
height: 100%
}
body {
margin: 0;
background: var(--bg);
color: var(--fg);
font-family: Inter, system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;
display: grid;
place-items: start center;
}
.container {
width: min(1080px, 94vw);
margin: 28px 0 40px;
background: linear-gradient(180deg, rgba(255, 255, 255, .02), rgba(255, 255, 255, .01));
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 24px 20px 18px;
}
.header {
text-align: center;
}
h1 {
margin: 0;
font-weight: 800;
letter-spacing: .2px;
}
.sub {
color: var(--muted);
margin: 6px 0 12px;
}
.search {
display: grid;
grid-template-columns: 1fr auto;
gap: 10px;
margin: 10px 0 6px;
}
.search input {
padding: 12px 14px;
border-radius: 12px;
border: 1px solid var(--line);
background: #0F172A;
color: var(--fg);
outline: none;
}
.btn {
border: 1px solid var(--line);
background: #0F172A;
color: var(--fg);
padding: 10px 16px;
border-radius: 12px;
cursor: pointer;
transition: transform .06s ease, opacity .2s ease;
}
.btn:hover {
transform: translateY(-1px);
opacity: .95;
}
.btn.primary {
background: var(--accent);
border: 0;
}
.status {
min-height: 1.2em;
color: var(--muted);
margin: 6px 0 12px;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 14px;
}
.card {
background: var(--card);
border: 1px solid var(--line);
border-radius: 14px;
overflow: hidden;
display: flex;
flex-direction: column;
}
.card img {
width: 100%;
height: 160px;
object-fit: cover;
display: block;
}
.card .body {
padding: 12px 12px 14px;
display: grid;
gap: 8px;
}
.card h3 {
margin: 0;
font-size: 1.05rem;
}
.meta {
color: #A1AEC6;
font-size: .9rem;
}
.ingredients {
color: #CBD5E1;
font-size: .95rem;
}
.actions {
display: flex;
gap: 8px;
margin-top: 6px;
flex-wrap: wrap;
}
.actions a,
.actions button {
border: 1px solid var(--line);
background: #0F172A;
color: #9AE6E2;
padding: 8px 10px;
border-radius: 10px;
text-decoration: none;
cursor: pointer;
}
.actions a:hover,
.actions button:hover {
opacity: .95;
transform: translateY(-1px);
}
.footer {
text-align: center;
color: var(--muted);
margin-top: 16px;
}
.footer a {
color: #9AE6E2;
text-decoration: none;
}
.footer a:hover {
text-decoration: underline;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
border: 0;
}
Why this CSS works
- CSS variables (
--teal-1
,--bg
) → make it easy to tweak colors. body
→ dark background, centered container..container
→ card-like box around our app..grid
(not shown yet) → will display recipe cards in a responsive grid..card
→ each recipe styled with image, title, ingredients.
👉 CSS is like the “makeup” for our HTML skeleton.
Step 3 — JavaScript (the brain)
This is where all the magic happens: fetching data, handling searches, rendering results.
0) The API we’re talking to
// ===== Recipe Finder (TheMealDB API) — Codeboid =====
const API = 'https://www.themealdb.com/api/json/v1/1/search.php?s=';
API
is the base URL for TheMealDB’s “search by name” endpoint.- We’ll append the user’s query to this string.
- No API key needed — perfect for beginners.
1) Get references to important DOM elements
const form = document.getElementById('searchForm');
const input = document.getElementById('q');
const status = document.getElementById('status');
const grid = document.getElementById('results');
- We grab references once so we don’t keep searching the page later.
form
→ to listen for submit,input
→ user text,status
→ live messages,grid
→ where cards will appear.
2) Two helpers for network behavior
let aborter = null;
let debounceId = null;
aborter
will hold an AbortController so we can cancel a fetch if the user keeps typing (prevents “race conditions” + wasted requests).debounceId
lets us wait a moment after the last keystroke before searching (feels fast, but not spammy).
Analogy: If someone asks you 6 questions mid-sentence, you wait until they finish talking, then answer the last one. Debouncing + aborting does that for your app.
3) Restore last search on load (friendly UX)
window.addEventListener('DOMContentLoaded', () => {
const last = localStorage.getItem('cb:recipe:last') || 'pasta';
input.value = last;
search(last);
});
DOMContentLoaded
fires when the HTML is parsed and ready.- We read
localStorage
— a tiny browser “sticky note” — using keycb:recipe:last
. - If nothing saved yet, default to
"pasta"
so the UI isn’t empty. - Set the input’s value and immediately search to show results.
Tip:
localStorage
stores strings only. We use it for tiny user preferences like the last query.
4) Submit = perform a search (no page reload)
form.addEventListener('submit', (e) => {
e.preventDefault();
search(input.value.trim());
});
- Forms try to reload the page by default.
e.preventDefault()
says “stay here.” trim()
removes accidental spaces.
5) Debounced search while typing (super snappy)
input.addEventListener('input', () => {
clearTimeout(debounceId);
debounceId = setTimeout(() => search(input.value.trim()), 400);
});
- For every keystroke, we reset a 400ms timer.
- If the user pauses for ~0.4s, we trigger
search()
. - This feels instant, but saves API calls when someone is still typing “p-a-s-t-a”.
6) The star of the show: search(query)
async function search(query) {
if (!query) {
setStatus('Type something like “pasta”, “chicken”, or “rice”.');
grid.innerHTML = '';
return;
}
- Empty query? Show a gentle hint and clear the grid.
// Save preference
try { localStorage.setItem('cb:recipe:last', query); } catch { }
- Save the last query.
- Wrapped in
try { } catch { }
because some strict browsers or privacy modes can throw an error when writing to storage. We ignore if it fails — the app still works.
// Cancel any in-flight request
if (aborter) aborter.abort();
aborter = new AbortController();
- If a previous fetch is still running, we cancel it.
- Create a new
AbortController
for the request we’re about to start. - This prevents old results from popping in “out of order.”
setStatus('Searching…');
grid.innerHTML = '';
- Clear old results, show a friendly “Searching…” so the UI feels alive.
try {
const res = await fetch(API + encodeURIComponent(query), { signal: aborter.signal });
fetch(url)
starts the network request.await
pauses here until the server responds.encodeURIComponent(query)
turns user text into a safe URL piece (e.g., spaces →%20
).{ signal: aborter.signal }
links the request to ourAbortController
, soabort()
can stop it.
if (!res.ok) throw new Error('Network error');
- If server replied with a status like 404 or 500, we throw to jump to
catch
.
const data = await res.json();
await res.json()
reads the body and parses JSON into a JavaScript object.- For TheMealDB, the shape is
{ meals: [...] }
or{ meals: null }
.
const meals = data.meals || [];
if (meals.length === 0) {
setStatus(`No recipes found for “${escapeHTML(query)}”.`);
return;
}
- If API returned
null
, we coerce to[]
so.length
works. - Show a friendly “no results” if empty.
setStatus(`Showing ${meals.length} result${meals.length !== 1 ? 's' : ''} for “${escapeHTML(query)}”.`);
render(meals);
- Tiny grammar touch (
result
vsresults
). - Then pass the array to
render()
to draw cards.
} catch (err) {
if (err.name === 'AbortError') return; // typing fast; ignore
setStatus('Something went wrong. Please check your connection and try again.');
}
}
- If we aborted on purpose (user kept typing), we quietly exit.
- Any other error → show a helpful message.
7) Render the results grid
function render(meals) {
grid.innerHTML = meals.map(toCardHTML).join('');
}
.map(toCardHTML)
turns each recipe object into a string of HTML..join('')
merges them into one big string (faster than manyappendChild
calls).- We then slam that into the grid’s
innerHTML
.
Safety note: we sanitize dynamic text in
toCardHTML
usingescapeHTML()
for anything we inject as text (like the title and snippets). You already did this correctly 👍.
8) Build one recipe card (template thinking)
function toCardHTML(m) {
const img = m.strMealThumb || '';
const title = m.strMeal || 'Untitled';
const area = m.strArea || 'Unknown';
const cat = m.strCategory || 'Recipe';
const youtube = m.strYoutube || '';
const source = m.strSource || '';
const ing = ingredients(m).slice(0, 8); // show first ~8 for brevity
const instr = (m.strInstructions || '').split('\n').filter(Boolean).slice(0, 2).join(' ');
const safeTitle = escapeHTML(title);
- We normalize missing fields with default values so the UI never breaks.
ingredients(m)
extracts up to 20 ingredient/measure pairs (see next section), but we show only 8 to keep cards tidy.strInstructions
can be long; we split by new lines, take the first 1–2 lines, and join for a tiny teaser.safeTitle
is the title after escaping special characters (prevents HTML injection).
return `
<article class="card">
<img src="${img}" alt="${safeTitle}" loading="lazy">
<div class="body">
<h3>${safeTitle}</h3>
<div class="meta">${escapeHTML(cat)} • ${escapeHTML(area)}</div>
<div class="ingredients"><strong>Ingredients:</strong> ${ing.map(escapeHTML).join(', ') || '—'}</div>
<p class="meta">${escapeHTML(instr)}${instr ? '…' : ''}</p>
<div class="actions">
${youtube ? `<a href="${youtube}" target="_blank" rel="noopener">YouTube</a>` : ''}
${source ? `<a href="${source}" target="_blank" rel="noopener">Source</a>` : ''}
<button type="button" onclick="alert('Full instructions are on the Source/YouTube links. Try different keywords for more results!')">Details</button>
</div>
</div>
</article>`;
}
- We build the card HTML using a template string.
loading="lazy"
defers images below the fold — nice perf win.- We escape any user/API text that becomes text content (title, meta, ingredients, snippet).
- We include YouTube and Source buttons only when links exist.
- The little “Details” button gives a friendly hint.
9) Extract ingredients (clever looping)
function ingredients(m) {
const list = [];
for (let i = 1; i <= 20; i++) {
const name = m[`strIngredient${i}`];
const amt = m[`strMeasure${i}`];
if (name && name.trim()) {
list.push([amt, name].filter(Boolean).join(' ').trim());
}
}
return list;
}
- TheMealDB exposes up to 20 pairs as
strIngredient1..20
andstrMeasure1..20
. - We loop 1→20, skip empty ones, and build strings like
"1 cup Flour"
. .filter(Boolean)
removes empty measure or ingredient parts.- Returns an array like
["1 kg Chicken", "2 tsp Salt", ...]
.
10) Status helper (tiny but mighty)
function setStatus(text) {
status.textContent = text;
}
- Updates the live status bar.
- Because your HTML has
aria-live="polite"
, screen readers will announce changes without being disruptive.
11) Safety: escape any dynamic text
function escapeHTML(s) {
return String(s).replace(/[&<>"']/g, c => ({
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
}[c]));
}
- This prevents unexpected HTML from the API from being interpreted as code.
- Essential whenever you inject text into the DOM via
innerHTML
.
Common “why” questions (beginner-friendly)
- Why
await
?await
pauses inside anasync
function until the promise resolves. It lets you write asynchronous code in a top-to-bottom style that’s easier to read than.then()
chains. - Why
encodeURIComponent
around the query?
It turns spaces and special characters into safe URL pieces (e.g.chicken curry
→chicken%20curry
). Without it, your request might break. - Why an AbortController?
If the user types quickly, you don’t want old fetches finishing later and overwriting new results.abort()
cancels the in-flight request. - Why map + join for rendering?
Building a big string and settinginnerHTML
once is fast and simple for static list renders. (For complex apps, you’d reach for a framework.)
Tiny accessibility wins you already have
role="search"
&aria-label
on the form.aria-live="polite"
for status updates.- Logical heading order and alt text for images.
- Keyboard-only users can tab to links and buttons in each card.
- Updates the status message at the top (like “Searching…”).
How to Use (User-Facing)
- Put all three files (
index.html
,style.css
,script.js
) in the same folder. - Open
index.html
in your browser. - In the search box, type an ingredient or dish (e.g., pasta, chicken, rice).
- Press Enter / Search — or just stop typing for a moment (auto-search kicks in after ~0.4s).
- Watch the status line update: Searching… → Showing N results for “…” (or No recipes found).
- Browse the recipe cards: photo, title, Category • Area, Ingredients (first 8), and a short instructions snippet.
- Click YouTube (video) or Source (full recipe) — they open in a new tab.
- Refresh the page — your last search reappears automatically (saved in
localStorage
).
Test Checklist
- Typing debounce → Waiting ~400 ms after you stop typing runs a search once (not on every keystroke).
- Submit → Pressing Enter runs a search without page reload.
- Abort in-flight → Typing quickly cancels older requests; only the latest results render (no flicker).
- Status line → Shows Searching…, then either the result count or No recipes found.
- Cards → Each card has image, title,
Category • Area
, up to 8 ingredients, a short instructions preview, and conditional YouTube/Source links. - Links → Open in a new tab with
rel="noopener"
. - Refresh → Input value and results persist via
localStorage
. - Empty query → Grid clears and a friendly hint appears.
- Lazy images → Offscreen images load as you scroll (
loading="lazy"
).
Troubleshooting (Quick Fixes)
- Nothing happens when I search → Check IDs match the JS:
searchForm
,q
,status
,results
. Ensure<script src="script.js"></script>
is at the end ofbody
. - Old results flash in → Use a modern browser;
AbortController
cancels earlierfetch
calls. SeeingAbortError
in the console is normal when typing fast. - Always “No recipes found” → Verify internet connectivity or try a common term (e.g., chicken). The API may be briefly down; try again.
- Special characters break search → We use
encodeURIComponent
in code; make sure that line wasn’t removed. - YouTube/Source missing on some cards → Not every meal includes those fields; buttons hide automatically when absent.
- Images not loading → Some API items have missing thumbnails; others will load fine as you scroll.
- LocalStorage not remembered → Private/strict modes can block storage; app still works, just won’t remember the last query.
- CORS or file URL quirk → Usually fine, but if your setup complains, serve with a tiny dev server (e.g., VS Code Live Server).
FAQ
Why TheMealDB?
Free, beginner-friendly, and no API key for simple searches — great for a first JavaScript recipe finder.
Can I search multi-word phrases?
Yes (e.g., chicken curry). We safely encode queries with encodeURIComponent
.
How does it remember my last search?
Via localStorage
(key: cb:recipe:last
). Some private modes may disable it.
Is it mobile-friendly?
Yep — the CSS grid is responsive; the search field uses a mobile-friendly input.
Where are the full instructions?
Click Source (full recipe page) or YouTube (video). Cards show a short preview only.
Why only 8 ingredients on cards?
For readability. Tweak ingredients(m).slice(0, 8)
to show more.
What happens if I type fast?
Debounce waits ~400 ms; AbortController cancels older requests so only the latest search renders.
Can I filter by category or area?
Not yet, but you can add dropdowns using strCategory
and strArea
fields from the API.
Will it work offline?
Not without a service worker. You can add one to cache the last search.
Is this an SEO-friendly HTML CSS JavaScript project?
Yes—clear headings, alt text, and a focused topic help. Use keywords like “HTML CSS JavaScript project” and “recipe finder project.”
Try It Yourself
Easy
- Change the default search from
"pasta"
to your favorite dish in theDOMContentLoaded
handler. - Show 12 ingredients instead of 8 (edit
.slice(0, 8)
). - Customize color variables in
:root
to match your brand.
Medium
- Add Favorites (⭐) saved to
localStorage
and a “Favorites” tab. - Add filters for Category and Area with simple dropdowns.
- Add a loading skeleton while results are fetching.
Hard
- Open a modal with the full
strInstructions
(and larger image). - Add pagination / Load more for very broad searches.
- Cache the last successful response and show it instantly on page load (then revalidate).
Codeboid Series Wrap-Up + Next Steps
You just shipped a practical HTML CSS JavaScript project that talks to a real API, feels snappy thanks to debounce + AbortController, and remembers preferences with localStorage — the Codeboid way: learn by doing. Nice work!
Next in Real-Life Web Projects: Post-it Wall 📝 (localStorage sticky notes)— create, edit, and delete colorful sticky notes that persist in the browser. Tag notes, search by text, and learn data modeling with simple objects.