Build a Recipe Finder with HTML, CSS & JavaScript

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:

Open index.html in your browser to run it.

Step 1 — HTML (structure) for your Recipe Finder project

Full code

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" and aria-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

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

  • 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

  • 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

  • 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)

  • DOMContentLoaded fires when the HTML is parsed and ready.
  • We read localStorage — a tiny browser “sticky note” — using key cb: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)

  • Forms try to reload the page by default. e.preventDefault() says “stay here.”
  • trim() removes accidental spaces.

5) Debounced search while typing (super snappy)

  • 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)

  • Empty query? Show a gentle hint and clear the grid.
  • 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.
  • 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.”
  • Clear old results, show a friendly “Searching…” so the UI feels alive.
  • 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 our AbortController, so abort() can stop it.
  • If server replied with a status like 404 or 500, we throw to jump to catch.
  • await res.json() reads the body and parses JSON into a JavaScript object.
  • For TheMealDB, the shape is { meals: [...] } or { meals: null }.
  • If API returned null, we coerce to [] so .length works.
  • Show a friendly “no results” if empty.
  • Tiny grammar touch (result vs results).
  • Then pass the array to render() to draw cards.
  • If we aborted on purpose (user kept typing), we quietly exit.
  • Any other error → show a helpful message.

7) Render the results grid

  • .map(toCardHTML) turns each recipe object into a string of HTML.
  • .join('') merges them into one big string (faster than many appendChild calls).
  • We then slam that into the grid’s innerHTML.

Safety note: we sanitize dynamic text in toCardHTML using escapeHTML() for anything we inject as text (like the title and snippets). You already did this correctly 👍.

8) Build one recipe card (template thinking)

  • 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).
  • 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)

  • TheMealDB exposes up to 20 pairs as strIngredient1..20 and strMeasure1..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)

  • 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

  • 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 an async 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 currychicken%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 setting innerHTML 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 of body.
  • Old results flash in → Use a modern browser; AbortController cancels earlier fetch calls. Seeing AbortError 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 the DOMContentLoaded 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 ProjectsPost-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.