A recipe discovery web app built with vanilla JavaScript, demonstrating a full MVC architecture — no frameworks, just modern JS.
- Overview
- Features
- Tech Stack
- Architecture
- Screenshots
- Getting Started
- Project Structure
- Key Engineering Challenges & Fixes
- Roadmap
- What I Learned
- Acknowledgements
- License
Forkify lets you search thousands of recipes, view detailed cooking instructions and ingredients, dynamically scale servings (with live ingredient recalculation), bookmark your favorites with persistent storage, and even upload your own custom recipes to share with others.
This project was built as a deep dive into vanilla JavaScript architecture — no React, no Vue, no state management library. Every pattern here (MVC, pub-sub, DOM diffing) is hand-rolled to demonstrate a genuine understanding of how those abstractions work under the hood.
- 🔍 Live recipe search with pagination through large result sets
- 📋 Detailed recipe view — ingredients, cooking time, servings, source link
- ⚖️ Dynamic servings adjustment — ingredient quantities recalculate in real time (using fraction display, e.g.
¼ cupinstead of0.25 cup) - 🔖 Bookmarking system with
localStoragepersistence — your bookmarks survive a page refresh - 📤 Upload your own recipes via a custom form, parsed and sent to a live API
- ⚡ Optimized re-rendering — a custom DOM-diffing algorithm updates only the elements that changed (e.g. servings numbers) instead of re-rendering the whole view, preserving scroll position and avoiding UI flicker
- 🌐 Hash-based routing — shareable URLs for individual recipes (
#recipeId) - ⏱️ Request timeout handling — API calls fail gracefully after a set timeout instead of hanging indefinitely
- 💥 Robust error handling — user-friendly error states for failed searches, missing recipes, and network issues
| Category | Technology |
|---|---|
| Language | JavaScript (ES6+) — classes, async/await, destructuring, optional chaining, private fields |
| Bundler | Parcel 2 (zero-config bundling, hot module reloading, asset imports) |
| Markup / Styling | Semantic HTML5, CSS3 (Flexbox, Grid, custom properties) |
| Polyfills | core-js, regenerator-runtime (for older browser support of async/await and modern JS features) |
| Utilities | fractional (converts decimal quantities into readable fractions, e.g. 1.5 → 1½) |
| API | Forkify API — recipe search, retrieval, and user-submitted recipe uploads |
| Version Control | Git & GitHub |
This project follows the MVC (Model-View-Controller) pattern, entirely hand-built without a framework:
┌─────────────┐ ┌──────────────┐ ┌─────────────┐
│ Model │◄────►│ Controller │◄────►│ Views │
│ (state.js) │ │(controller.js)│ │(*.view.js) │
└─────────────┘ └──────────────┘ └─────────────┘
│ │
▼ ▼
API calls, DOM rendering,
data transforms, event listeners,
localStorage pub-sub handlers
Key architectural decisions:
- Publisher-Subscriber pattern — views never call the controller directly. Instead, each view exposes an
addHandlerX(handler)method that subscribes a controller function to a DOM event (click, hashchange, submit). This keeps views decoupled from business logic entirely. - Abstract base
Viewclass — all views (RecipeView,ResultsView,BookmarksView,PaginationView,AddRecipeView) extend a sharedViewclass that providesrender(),renderSpinner(),renderError(), and a customupdate()method for surgical DOM patching. - Single source of truth — a central
stateobject inmodel.jsholds all application data (current recipe, search results, bookmarks, pagination). Views are always a pure reflection ofstate— they never hold their own independent data.
📷 [Homepage / Search Results]
📷 [Recipe Detail View]
📷 [Servings Adjustment in Action (GIF)]
📷 [Bookmarks Panel]
📷 [Upload Recipe Form]
Tip: use ScreenToGif (Windows) or Kap (Mac) to record short GIFs of interactions like servings adjustment or bookmarking — these are what recruiters/visitors notice first.
- Node.js (v16+ recommended)
- npm or yarn
# Clone the repository
git clone https://github.com/yourusername/forkify.git
# Navigate into the project
cd forkify
# Install dependencies
npm install
# Start the development server
npm startThe app will open automatically at http://localhost:1234 (or your configured Parcel port).
npm run buildforkify/
├── src/
│ ├── js/
│ │ ├── model.js # State + API calls + business logic
│ │ ├── controller.js # Glue between model and views
│ │ ├── config.js # API URLs, timeouts, constants
│ │ ├── helpers.js # AJAX helper with timeout race
│ │ └── views/
│ │ ├── view.js # Abstract base View class
│ │ ├── recipeView.js
│ │ ├── searchView.js
│ │ ├── resultsView.js
│ │ ├── paginationView.js
│ │ ├── bookmarksView.js
│ │ ├── previewView.js
│ │ └── addRecipeView.js
│ ├── img/
│ │ └── icons.svg
│ ├── sass/ # (if applicable)
│ └── index.html
├── package.json
└── README.md
Building this project surfaced a number of real-world JavaScript bugs — the kind that don't show up in tutorials but absolutely show up in production code. Documenting them here as a demonstration of debugging process, not just feature-building:
| Bug | Root Cause | Fix |
|---|---|---|
| Icons failing to load app-wide | url: asset import already resolves to the full icon sprite URL, but .svg was appended a second time (${icons}.svg#icon-x) |
Removed the redundant extension across all <use href> references |
| Ingredients list crashing on render | Private method #generateMarkupIngredient was invoked via a mismatched public reference (this._generateMarkupIngredient) |
Corrected the method name to match its declaration |
| Pagination buttons never appeared | _generateMarkup() computed the correct markup but discarded it — every code path fell through to an unconditional return '' |
Added explicit return statements to each conditional branch |
| Wrong bookmark always got deleted | Assignment operator (=) used instead of comparison (===) inside Array.prototype.findIndex, silently corrupting the object's id in the process |
Fixed to el.id === id; added regression awareness around =/=== bugs, since they fail silently rather than throwing |
| Un-bookmarking silently failed | A leftover duplicate function call re-added the bookmark immediately after deleting it, in the same click handler | Removed the redundant call |
update() crashing on list-type views |
The custom DOM-diffing algorithm assumes matching structure length between old and new DOM — safe for value-only updates (servings), unsafe for variable-length lists (bookmarks, search results) | Restricted update() to fixed-structure views only; list views use full render() instead |
| "Method is not a function" runtime errors | Typos between a method's declaration and its call site (_addHandlerHideWidnow vs. _addHandlerHideWindow), compounded by Parcel's aggressive build caching masking whether a fix had actually taken effect |
Corrected method name mismatches; adopted a habit of clearing .parcel-cache and dist after structural changes |
| Async race conditions on rapid search | Fast repeated searches could resolve out of order, rendering stale results over fresh ones | Addressed via consistent state-driven rendering (render always reads from state, never from a captured local closure) |
Planned improvements to keep pushing this project further:
- TypeScript migration — add static typing across
model,views, and API response shapes to catch a whole class of bugs (like the ones documented above) at compile time instead of runtime - Unit & integration tests (Jest) for model logic — especially bookmark CRUD operations and ingredient parsing
- Debounced search input to reduce redundant API calls while typing
- Dark mode toggle with persisted preference
- Recipe categories/filters (cuisine, dietary restrictions, cooking time)
- Skeleton loading states instead of a single spinner, for a more polished perceived-performance feel
- PWA support — offline access to bookmarked recipes via service workers
- Accessibility audit — ARIA labels, keyboard navigation, focus trapping in the upload modal
- CI pipeline (GitHub Actions) — lint + build check on every push
This project was as much about debugging discipline as it was about writing new features. Key takeaways:
- How silent failures (like
=vs===, or a missingreturnin a conditional branch) are far more dangerous than thrown errors — they don't announce themselves, they just quietly produce wrong behavior. - Why bundler caching (Parcel specifically) can make a correct fix look like it didn't work, and the discipline of clearing
.parcel-cache/distbefore concluding a fix failed. - The tradeoffs of hand-rolled DOM diffing vs. reaching for a framework — and why frameworks like React exist once you've felt the edge cases of maintaining your own
update()method. - The real value of the Publisher-Subscriber pattern for keeping vanilla JS codebases maintainable as they grow past a handful of files.
- Built as the capstone project of Jonas Schmedtmann's "The Complete JavaScript Course"
- Recipe data provided by the Forkify API
- Icons from Feather Icons
This project is licensed under the MIT License — see the LICENSE file for details.
⭐ If you found this project interesting, consider giving it a star!