Build a Movie Search App in JavaScript
Fetch real movie data, debounce searches, and manipulate the DOM by hand.
Introduction
30 Second Summary
Every time you search for something online, your results appear instantly as you type. Behind that experience is a pattern of fetching data, transforming it, and updating the page in real time.
In this project, you will build a movie search app from scratch using vanilla JavaScript. You will fetch live movie data from an API, debounce your search input, transform results with array methods, and render everything to the page by hand.
What You'll Build
You will type a movie title into a search box and watch real movie posters, titles, and details appear on the page automatically, powered entirely by your own JavaScript code with no frameworks involved.
By the end of this project, you'll have:
- A live search that fetches real movie data from the OMDb API and displays results as cards with posters, titles, and release years.
- A hand-written debounce function that uses closures to remember a timer between keystrokes, preventing wasteful API calls while you type.
- A detail panel that slides open when you click any movie card, showing the full plot, director, cast, and IMDb rating fetched with a second API call.
- Secret Mission: Add a watchlist feature that saves your favorite movies to localStorage so they persist across page reloads.
Are there any prerequisites?
You should have basic HTML knowledge and a code editor ready. No prior JavaScript experience with APIs or DOM manipulation is required. This project teaches those patterns from scratch.
Before We Start
Before We Start...
Before we dive in, let's lock in what you're about to build and why it matters.
You're going to build a movie search app from scratch using vanilla JavaScript. No React, no Vue, just you and the browser APIs that power every framework under the hood.
Set Up Your Project Files and API Key
To build a movie search app, you need two things: files for the browser to load, and access to real movie data.
In this step, you will create a project folder with your HTML, CSS, and JavaScript files. Then you will sign up for a free API key from the OMDb API so your app can fetch real movie information.
In this step, get ready to:
- Create a project folder with index.html, style.css, and app.js.
- Sign up for a free OMDb API key.
- Open the page in your browser and confirm the styled layout.
Create your project folder and HTML page
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal (macOS) or Command Prompt (Windows) and press Enter to open it.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir movie-search-app
cd movie-search-app
- Create the three project files by running this command:
touch index.html style.css app.js
- Confirm the files exist by running:
ls
You should see index.html, style.css, and app.js listed in the output.
Don't see all three files?
Make sure you ran the touch command while inside the movie-search-app folder. Run pwd to check your current directory.
help me troubleshoot my project folder setup
- Open the movie-search-app folder in your code editor.
- Open index.html and paste the following HTML:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Movie Search App</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="app">
<header>
<h1>Movie Search</h1>
<div class="search-container">
<input type="text" id="search-input" placeholder="Search for movies...">
<div id="search-status"></div>
</div>
</header>
<main>
<div id="results-container"></div>
<div id="detail-panel" class="hidden"></div>
</main>
</div>
<script src="app.js"></script>
</body>
</html>
What does this HTML do?
- The <link> tag connects your CSS file, and the <script> tag connects your JavaScript file.
- The search-input element is where users will type movie names.
- The results-container and detail-panel divs are empty containers that your JavaScript will fill with movie data later.
- Save index.html.
- Open index.html in your browser by double-clicking the file in Finder (macOS) or File Explorer (Windows).
You should see the text "Movie Search" and a search input field on a plain white background. The page looks bare because there is no CSS yet.
Add the CSS stylesheet
The CSS gives your app a dark cinema-style theme with a responsive grid layout for movie cards. You will paste it in sections because the full stylesheet is long.
- Open style.css in your code editor and paste these base layout styles:
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background-color: #1a1a2e;
color: #eee;
min-height: 100vh;
}
.app {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
header {
text-align: center;
margin-bottom: 2rem;
}
header h1 {
font-size: 2.5rem;
margin-bottom: 1rem;
color: #e94560;
}
What does this section do?
- The * reset removes default browser margins so spacing is consistent.
- The body sets the dark navy background (#1a1a2e) and light text colour.
- The header h1 gives your title the accent red colour (#e94560).
- Save style.css and refresh your browser.
You should see the dark navy background and a red "Movie Search" heading. The search input is there but unstyled.
Still seeing a white page?
Check that your index.html has <link rel="stylesheet" href="style.css"> in the <head> section. Also confirm that style.css is saved in the same folder as index.html.
help me debug why my CSS isn't loading
- Below the code you just pasted, add the search input styles:
.search-container {
max-width: 500px;
margin: 0 auto;
}
#search-input {
width: 100%;
padding: 0.75rem 1rem;
font-size: 1rem;
border: 2px solid #333;
border-radius: 8px;
background-color: #16213e;
color: #eee;
outline: none;
transition: border-color 0.3s;
}
#search-input:focus {
border-color: #e94560;
}
What does this section do?
- The #search-input styles the text field with a dark background and rounded corners.
- The :focus rule highlights the border in red when the user clicks into the field.
- Continue pasting below your existing code. Add the status text and results grid styles:
#search-status {
margin-top: 0.5rem;
font-size: 0.875rem;
color: #aaa;
min-height: 1.25rem;
}
#results-container {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 1.5rem;
}
What does this section do?
- The #search-status reserves space for feedback messages like "Searching..." or result counts.
- The #results-container uses CSS Grid to automatically arrange movie cards in a responsive layout.
- Save style.css and refresh your browser.
You should now see a styled search input with rounded corners and a dark background. Click into it and the border turns red.
- Continue adding to style.css below your existing code. Paste the movie card styles:
.movie-card {
background-color: #16213e;
border-radius: 8px;
overflow: hidden;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
}
.movie-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 25px rgba(233, 69, 96, 0.2);
}
.movie-card img {
width: 100%;
height: 300px;
object-fit: cover;
}
.movie-info {
padding: 0.75rem;
}
.movie-info h3 {
font-size: 0.95rem;
margin-bottom: 0.25rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
What does this section do?
- The .movie-card styles create the rounded containers that will hold each movie result.
- The :hover rule lifts cards up slightly and adds a red glow when the user hovers.
- The text-overflow: ellipsis truncates long movie titles with "..." instead of overflowing.
- Paste the detail panel and info text styles below your existing code:
.movie-info p {
font-size: 0.8rem;
color: #aaa;
text-transform: capitalize;
}
#detail-panel {
position: fixed;
top: 0;
right: 0;
width: 100%;
max-width: 600px;
height: 100vh;
background-color: #16213e;
padding: 2rem;
overflow-y: auto;
box-shadow: -4px 0 20px rgba(0, 0, 0, 0.5);
z-index: 100;
}
#detail-panel.hidden {
display: none;
}
What does this section do?
- The #detail-panel is a fixed panel that will slide in from the right to show movie details.
- The .hidden class hides the panel until a user clicks a movie card.
- Save style.css and refresh your browser.
The page looks the same as before because the card and detail panel styles only take effect once JavaScript adds movie elements. The dark background and styled input confirm the CSS is loading correctly.
- Paste the detail content styles below your existing code:
#close-detail {
position: absolute;
top: 1rem;
right: 1rem;
background: none;
border: none;
color: #eee;
font-size: 2rem;
cursor: pointer;
}
.detail-content img {
width: 100%;
max-width: 300px;
border-radius: 8px;
margin-bottom: 1rem;
}
.detail-info h2 {
font-size: 1.5rem;
margin-bottom: 0.5rem;
color: #e94560;
}
.detail-meta {
color: #aaa;
margin-bottom: 1rem;
}
What does this section do?
- The #close-detail positions the X button in the top-right corner of the detail panel.
- The .detail-info h2 styles the movie title inside the detail view with the accent red colour.
- Paste the last few utility styles below your existing code:
.detail-plot {
line-height: 1.6;
margin-bottom: 1rem;
}
.detail-info p {
margin-bottom: 0.5rem;
}
.loading {
color: #aaa;
font-style: italic;
}
.error {
color: #e94560;
}
What does this section do?
- The .loading and .error classes style the status messages your JavaScript will display during API calls.
- The .detail-plot gives movie plot text comfortable line spacing for readability.
- Save style.css and refresh your browser.
The visible page should still show the dark background, red heading, and styled search input. All the remaining styles are ready for the dynamic content your JavaScript will create.
✔️ Awesome, I've got everything!
Great. Double check you have saved style.css and that the dark background is visible in your browser.
ⓧ I'd like to double check the full code
Your style.css should contain the following sections in this order. Scroll up and confirm each is present:
- Base layout styles (*, body, .app, header, header h1).
- Search input styles (.search-container, #search-input, #search-input:focus, #search-status, #results-container).
- Movie card styles (.movie-card, .movie-card:hover, .movie-card img, .movie-info, .movie-info h3).
- Detail panel styles (.movie-info p, #detail-panel, #detail-panel.hidden).
- Detail content styles (#close-detail, .detail-content img, .detail-info h2, .detail-meta).
- Utility styles (.detail-plot, .detail-info p, .loading, .error).
Get your OMDb API key
The OMDb API provides movie data that your app will fetch. You need a free API key to authenticate your requests.
- Open https://www.omdbapi.com/apikey.aspx in your browser.
- Select the FREE! (1,000 daily limit) account type.
- Enter your email address in the Email field.
- Enter your name in the First and Last Name field.
- Type Movie search learning project in the Use field.
- Click Submit.
- Check your email inbox for a message from OMDb containing your API key.
Check your spam folder
The OMDb confirmation email sometimes lands in spam, especially with Yahoo and Microsoft accounts. If you do not see it within a few minutes, check your spam or junk folder.
- Open app.js in your code editor and paste this line, replacing the placeholder with your actual API key from the email:
const API_KEY = 'your-api-key-here';
What does this line do?
This stores your API key in a constant so every fetch request in your app can use it. Replace your-api-key-here with the actual key string from your email (it looks like a short alphanumeric code).
- Save app.js.
Keep your API key private
Do not share your API key publicly or commit it to a public repository. For this learning project it lives directly in your file, but in production you would store it in an environment variable.
Before you check the final result, what do you expect to see when you refresh the page?
- Refresh your browser (or reopen index.html if you closed it).
You should see the styled "Movie Search" heading in red on a dark background, with a rounded search input below it. The page does not do anything yet when you type, because you have not written any JavaScript logic.
Your project files are ready and your API key is in place. Next up, you will write JavaScript that fetches real movie data every time you type in the search box.
Fetch Movies on Every Keystroke
Your project structure is ready and your API key is active. Now it is time to make the search box actually do something.
The fastest way to get results on screen is to fire a fetch request every time the user presses a key. This gives you instant feedback, but it also sets up a problem you will feel in the next step.
In this step, get ready to:
- Write an async function that calls the OMDb search API and handles the response.
- Build a function that creates movie cards and adds them to the page.
- Wire the search input so every keystroke triggers an API call.
Write the search function
Before you can search for movies, your JavaScript needs to know where the API lives and which HTML elements to update. You will set up those references, then write the function that talks to the OMDb API.
- In your code editor, open app.js.
- Add the following code below the existing const API_KEY line:
const BASE_URL = 'https://www.omdbapi.com/';
// Grab references to the HTML elements we need to update
const searchInput = document.getElementById('search-input');
const resultsContainer = document.getElementById('results-container');
const detailPanel = document.getElementById('detail-panel');
const searchStatus = document.getElementById('search-status');
// Fetch movies from OMDb API
async function searchMovies(query) {
searchStatus.textContent = 'Searching...';
const response = await fetch(
`${BASE_URL}?apikey=${API_KEY}&s=${encodeURIComponent(query)}`
);
const data = await response.json();
if (data.Response === 'False') {
searchStatus.textContent = data.Error;
resultsContainer.innerHTML = '';
return;
}
displayResults(data.Search);
}
What does this code do?
BASE_URL stores the OMDb API endpoint so you do not repeat the full URL every time you fetch.
The four document.getElementById calls grab the HTML elements your code will update. Caching them in variables avoids searching the DOM repeatedly.
searchMovies is an async function that uses await to pause until the network response arrives. It builds a URL with your API key and the search query, then checks whether the API returned an error (Response: 'False'). If the search succeeded, it passes the results array to displayResults.
- Save app.js.
- Switch to your browser and refresh the page.
- Open the browser console by pressing Cmd+Option+J (macOS) or Ctrl+Shift+J (Windows).
- Type searchMovies('batman') into the console and press Enter.
You will see "Searching..." appear below the search input. The console will show an error about displayResults not being defined. That is expected because you have not written it yet. The important thing is the Network tab shows a successful request to omdbapi.com.
Not seeing a network request?
- Make sure the Network tab is open in DevTools before you run the command. Requests fired before the tab opens do not appear.
- Check that your API_KEY value in app.js is the actual key from your email, not the placeholder text.
- Confirm you saved app.js and refreshed the browser page after saving.
Help me debug why my searchMovies function is not reaching the OMDb API.
Display movie cards on the page
The API is responding with data. Now you need a function that takes that data and builds visible movie cards in the browser.
- Add the following function below searchMovies in app.js:
// Display movie cards in the DOM
function displayResults(movies) {
resultsContainer.innerHTML = '';
movies.forEach(movie => {
const card = document.createElement('div');
card.className = 'movie-card';
card.dataset.id = movie.imdbID;
card.innerHTML = `
<img src="${movie.Poster}" alt="${movie.Title}">
<div class="movie-info">
<h3>${movie.Title}</h3>
<p>${movie.Year} · ${movie.Type}</p>
</div>
`;
resultsContainer.appendChild(card);
});
}
What does this code do?
displayResults first clears any previous results with innerHTML = ''. Then it loops over the movies array with forEach.
For each movie, it creates a new div element, sets its class to movie-card (which your CSS already styles), and fills it with an image and text using a template literal. Finally, appendChild attaches the card to the results container so it appears on the page.
- Save app.js.
Wire up the search input
Both functions exist, but nothing calls them when you type. You need an event listener that fires searchMovies every time the input value changes.
- Add this code at the bottom of app.js:
// Fire a search on every keystroke
searchInput.addEventListener('input', (event) => {
searchMovies(event.target.value);
});
What does this code do?
The input event fires every time the text in the search box changes. This listener grabs the current value and passes it straight to searchMovies. Every single keystroke triggers a full API call.
- Save app.js.
- Refresh the browser page.
- Open the Network tab in DevTools (press Cmd+Option+I on macOS or Ctrl+Shift+I on Windows, then click the Network tab).
Before you type anything, take a guess: if you type the word "batman" (6 letters), how many network requests do you think will fire?
- Type batman into the search box.
You will see movie cards appear on the page showing posters, titles, and years. Check the Network tab. You should see 6 separate requests to omdbapi.com. One request fired for every single letter you typed.
Not seeing movie cards?
- Check the console for errors. A TypeError mentioning forEach usually means data.Search is undefined. Verify your API key is valid.
- Make sure you typed movie-card as the class name in displayResults. A mismatch means the CSS styles will not apply.
- Check that both functions (searchMovies and displayResults) are in app.js and the event listener is at the bottom after both functions.
Help me figure out why my movie search is not displaying results.
✔️ Awesome, I've got everything!
Great. Make sure you have saved app.js and move on.
ⓧ I'd like to double check the full code
Here is what your complete app.js should look like at this point:
const API_KEY = 'your-api-key-here';
const BASE_URL = 'https://www.omdbapi.com/';
const searchInput = document.getElementById('search-input');
const resultsContainer = document.getElementById('results-container');
const detailPanel = document.getElementById('detail-panel');
const searchStatus = document.getElementById('search-status');
async function searchMovies(query) {
searchStatus.textContent = 'Searching...';
const response = await fetch(
`${BASE_URL}?apikey=${API_KEY}&s=${encodeURIComponent(query)}`
);
const data = await response.json();
if (data.Response === 'False') {
searchStatus.textContent = data.Error;
resultsContainer.innerHTML = '';
return;
}
displayResults(data.Search);
}
function displayResults(movies) {
resultsContainer.innerHTML = '';
movies.forEach(movie => {
const card = document.createElement('div');
card.className = 'movie-card';
card.dataset.id = movie.imdbID;
card.innerHTML = `
<img src="${movie.Poster}" alt="${movie.Title}">
<div class="movie-info">
<h3>${movie.Title}</h3>
<p>${movie.Year} · ${movie.Type}</p>
</div>
`;
resultsContainer.appendChild(card);
});
}
searchInput.addEventListener('input', (event) => {
searchMovies(event.target.value);
});
The search works. Movie cards appear, posters load, and titles display correctly. But 6 API calls for one word is wasteful. In the next step, you will write a debounce function that waits for the user to stop typing before sending a single request.
Implement Debouncing with Closures
Your movie search works. Type a word, see results. But if you looked at the Network tab in the last step, you noticed the problem: every single keystroke fires a separate API request.
Debouncing solves this by waiting until the user stops typing before sending the request. You'll write a debounce function from scratch, which forces you to understand closures: a function that remembers variables from its outer scope even after that scope has closed.
In this step, get ready to:
- Write a debounce function that uses a closure to track a timer between calls.
- Replace the direct event listener with a debounced version that waits 500ms.
- Verify in the Network tab that only one request fires when you type quickly.
Write the debounce function
A debounce function takes another function and a delay, then returns a new function that only runs the original after the delay has passed without another call. The trick is the let timer variable: it lives in the outer function's scope, but the inner function can still read and write it every time it's called.
- In app.js, find the blank line between your DOM references and the searchMovies function.
- Add the following debounce function in that gap:
// Debounce function - uses a closure to remember the timer
function debounce(func, delay) {
let timer;
return function (...args) {
clearTimeout(timer);
timer = setTimeout(() => {
func(...args);
}, delay);
};
}
What does this code do?
This is a closure in action. The let timer variable is declared once when debounce is called. The returned function "remembers" that variable across every future call.
- Each time the returned function runs, it cancels the previous timer with clearTimeout(timer).
- Then it sets a new timer. Only if no new call arrives within delay milliseconds does the original function actually fire.
- The ...args spread passes whatever arguments the caller provides through to the original function.
Why is this a closure?
A closure is a function that accesses variables from an outer scope that has already finished executing. Here, debounce finishes and returns, but the inner function still holds a reference to timer. That's the same pattern that powers React hooks under the hood.
Wire up the debounced search
Now you need to create a debounced version of searchMovies and point the event listener at it instead of calling searchMovies directly.
- In app.js, scroll to the bottom of the file and find these lines:
searchInput.addEventListener('input', (event) => {
searchMovies(event.target.value);
});
- Replace those lines with the following code:
// Wire up the debounced search
const debouncedSearch = debounce(searchMovies, 500);
searchInput.addEventListener('input', (event) => {
debouncedSearch(event.target.value);
});
What changed?
- debounce(searchMovies, 500) creates a new function that wraps searchMovies with a 500ms delay.
- The event listener now calls debouncedSearch instead of searchMovies directly. Every keystroke resets the 500ms timer rather than firing a request.
- Save app.js.
Seeing a syntax error in the console?
- Make sure the debounce function is placed above the line that calls it. JavaScript needs to see the function definition before const debouncedSearch = debounce(searchMovies, 500) runs.
- Check that you removed the old searchInput.addEventListener block entirely. Having two event listeners means the old one still fires on every keystroke.
Help me debug my debounce function
Test the debounce in the Network tab
Before you test, think about what you expect to see. In the last step, typing "batman" fired 6 separate requests (one per letter). What do you think happens now with debouncing set to 500ms?
- Refresh your browser with index.html open.
- Open DevTools by pressing Cmd+Option+I (macOS) or Ctrl+Shift+I (Windows).
- Click the Network tab.
- Type "batman" quickly in the search box (all at once, without pausing between letters).
You should see only 1 request fire in the Network tab, about 500ms after you stop typing. The results still appear correctly. The closure held the timer across all 6 keystrokes, cancelling and resetting it each time until you stopped.
- Now try typing "batman" slowly, pausing about one second between each letter.
This time you'll see one request per letter, because each 500ms window expires before the next keystroke arrives. The closure resets each time, but the timeout completes because nothing interrupts it.
Why does this matter beyond this project?
This is the same pattern that powers React's useDebouncedValue hook. The hook uses a closure internally to track the timer across re-renders. You just built the mechanism by hand.
✔️ Awesome, I've got everything!
Great. Make sure you've saved app.js and confirmed the single-request behaviour in the Network tab.
ⓧ I'd like to double check the full code
Your app.js should have these sections in order. Scroll up and confirm each is present:
- API_KEY and BASE_URL constants at the top.
- Four DOM reference constants (searchInput, resultsContainer, detailPanel, searchStatus).
- The debounce(func, delay) function with let timer inside it.
- The searchMovies(query) async function.
- The displayResults(movies) function.
- const debouncedSearch = debounce(searchMovies, 500) followed by the addEventListener calling debouncedSearch.
You just solved the spam problem with a single function and a closure. Next up, you'll clean up the raw API data with array methods and add proper error handling so your app is resilient to bad searches and network failures.
Transform Data with Array Methods and Handle Errors
Your search works and the debounce keeps it efficient. But the raw data coming back from OMDb is messy.
Some movies come back without poster images, and the field names are inconsistently cased (Title instead of title, imdbID instead of id). Array methods like filter and map let you clean and reshape this data declaratively. This is the same pattern you use inside React's JSX when you write .map() to render lists.
In this step, get ready to:
- Add a filter/map pipeline to clean incoming movie data.
- Wrap the fetch call in try/catch to handle network errors gracefully.
- Add an early return that prevents wasted API calls on empty input.
Add the filter and map pipeline
The OMDb API returns movies where the Poster field is literally the string N/A when no image exists. Displaying these creates broken image cards. You also want consistent lowercase property names in your app code, regardless of what the API sends.
- In app.js, find this line inside searchMovies:
displayResults(data.Search);
- Replace that single line with this pipeline that filters and reshapes the data before displaying it:
const movies = data.Search
.filter(item => item.Poster !== 'N/A')
.map(item => ({
id: item.imdbID,
title: item.Title,
year: item.Year,
type: item.Type,
poster: item.Poster
}));
searchStatus.textContent = `Found ${data.totalResults} results`;
displayResults(movies);
What does this code do?
This is a two-step pipeline that processes the raw API data before your app uses it.
- .filter(item => item.Poster !== 'N/A') removes any movie without a real poster image.
- .map(item => ({ ... })) transforms each remaining movie into a new object with clean, lowercase property names your code controls.
- The status line now shows the total result count so the user knows how many matches exist.
Your displayResults function still expects the old property names (movie.Poster, movie.Title). You need to update it to match the new cleaned format.
- Replace your entire displayResults function with this updated version that uses the new property names:
// Display movie cards in the DOM
function displayResults(movies) {
resultsContainer.innerHTML = '';
movies.forEach(movie => {
const card = document.createElement('div');
card.className = 'movie-card';
card.dataset.id = movie.id;
card.innerHTML = `
<img src="${movie.poster}" alt="${movie.title}">
<div class="movie-info">
<h3>${movie.title}</h3>
<p>${movie.year} · ${movie.type}</p>
</div>
`;
resultsContainer.appendChild(card);
});
}
What changed here?
- movie.imdbID became movie.id (shorter, consistent).
- movie.Poster became movie.poster (lowercase).
- movie.Title, movie.Year, and movie.Type are now movie.title, movie.year, and movie.type.
Your app now controls the shape of its own data, regardless of what the external API sends.
- Save app.js and refresh your browser.
- Type game in the search box. You should see movie cards appear, and every card has a valid poster image (no broken images).
- Check the status text below the search input. It now shows a result count like Found 394 results.
Seeing broken images or undefined text?
- Make sure you updated ALL the property names in displayResults. If you see undefined in your cards, one of the old uppercase names is still there.
- Check that the .map() return object uses id: not imdbID: on the left side. The right side reads from the API (item.imdbID), the left side is your new name (id).
- help me debug why my movie cards show undefined instead of titles
Wrap the fetch in try/catch
Right now, if your network drops while searching, the app crashes silently. The fetch API only rejects its promise on true network failures. It does NOT throw on HTTP error status codes like 4xx or 5xx. You need to handle both cases explicitly.
- In app.js, find this line inside searchMovies:
searchStatus.textContent = 'Searching...';
- Immediately after that line, add try { to open a try block. All of the code below it (from const response through displayResults(movies)) should be indented inside this try block.
- Add this check immediately after the fetch resolves, between the await fetch(...) line and the const data line:
// Check for HTTP errors (fetch doesn't throw on 4xx/5xx)
if (!response.ok) {
throw new Error(`HTTP error: ${response.status}`);
}
- After the displayResults(movies); line, close the try block and add this catch block:
} catch (error) {
searchStatus.textContent = `Error: ${error.message}`;
resultsContainer.innerHTML = '';
}
What does try/catch do here?
- The try block wraps all the code that might fail (network request, JSON parsing).
- response.ok is false for any HTTP status outside 200-299. You throw manually because fetch treats those as successful responses.
- The catch block catches both network failures AND the HTTP error you threw. Either way, the user sees a clear message instead of a silent crash.
Before you test this, what do you think will happen if you search with your internet disconnected?
- Save app.js.
- Open DevTools in your browser (press Cmd+Option+I on macOS or Ctrl+Shift+I on Windows).
- Click the Network tab, then check the Offline checkbox to simulate a network disconnect.
- Type anything in the search box. You should see Error: Failed to fetch appear in the status text instead of the page crashing.
- Uncheck Offline when you are done testing.
Not seeing the error message?
- Make sure you opened the try { BEFORE the const response = await fetch( line. The fetch must be inside the try block to be caught.
- Check that you closed the try block with } before the catch keyword. Mismatched braces cause syntax errors.
- help me fix my try/catch block in searchMovies
Add an early return for empty queries
There is one more edge case. If the user clears the search box completely, the app still fires an API call with an empty string. The OMDb API returns an error for that, which briefly flashes in the status text. An early return prevents this wasted call.
- In app.js, find the opening of the searchMovies function:
async function searchMovies(query) {
- Add this guard clause as the very first thing inside the function, before the searchStatus.textContent = 'Searching...' line:
// Bail out if the search box is empty
if (!query.trim()) {
resultsContainer.innerHTML = '';
searchStatus.textContent = '';
return;
}
What does this code do?
- query.trim() removes whitespace from both ends. If nothing is left, the string is empty or just spaces.
- Clearing the results and status keeps the UI clean when the user deletes their search text.
- return exits the function immediately, skipping the fetch entirely.
- Save app.js and refresh your browser.
- Type batman in the search box to see results appear.
- Select all the text in the search box and delete it. The movie cards and status text should disappear immediately with no error flash.
Still seeing a brief error flash when clearing?
- Make sure the early return is the FIRST thing inside searchMovies, before searchStatus.textContent = 'Searching...'. If it comes after, the status text briefly shows before the return fires.
- help me position the early return correctly in my searchMovies function
Now run the full verification. Search for xyznonexistent in the search box. What do you expect to see?
You should see Movie not found! in the status text. This is the OMDb API's own error message, displayed gracefully by your data.Response === 'False' check.
✔️ Awesome, I've got everything!
Great. Double check you have saved app.js and all three scenarios work: filtered results, not-found message, and offline error.
ⓧ I'd like to double check the full code
Scroll up through your work and confirm these sections appear in app.js, in this order:
- The API_KEY and BASE_URL constants at the top.
- Four DOM references (searchInput, resultsContainer, detailPanel, searchStatus).
- The debounce function with let timer closure.
- The searchMovies function with: early return guard, try/catch wrapping the fetch, response.ok check, .filter().map() pipeline, and result count status.
- The updated displayResults function using lowercase properties (movie.id, movie.poster, movie.title, movie.year, movie.type).
- The debouncedSearch constant and the input event listener at the bottom.
Your search now handles dirty data, network failures, and empty input gracefully. Next up, you will build a detail panel that slides in when you click a movie card, fetching the full plot, director, and cast.
Build a Movie Detail Panel
Your movie cards display search results, but clicking one does nothing yet. A real movie app lets users drill into a title and see the full story.
In this step, you will write a second fetch call that loads a movie's full details by its IMDb ID. You will also wire click handlers onto dynamically-created elements. This is the exact "click triggers data load" pattern that powers every React list-to-detail view.
In this step, get ready to:
- Write a showDetail function that fetches and displays full movie details.
- Wire click listeners on movie cards to trigger the detail fetch.
- Add a close button to dismiss the detail panel.
Write the showDetail fetch logic
When a user clicks a movie card, you need to fetch that specific movie's full data from the OMDb API using its IMDb ID. The i parameter on the API returns a single movie's complete record, including plot, director, cast, and rating.
- In app.js, find the displayResults function.
- Add the following showDetail function directly above it:
// Fetch and display full movie details
async function showDetail(imdbID) {
detailPanel.classList.remove('hidden');
detailPanel.innerHTML = '<p class="loading">Loading details...</p>';
try {
const response = await fetch(
`${BASE_URL}?apikey=${API_KEY}&i=${imdbID}&plot=full`
);
if (!response.ok) {
throw new Error(`HTTP error: ${response.status}`);
}
const movie = await response.json();
} catch (error) {
detailPanel.innerHTML = `<p class="error">Failed to load details: ${error.message}</p>`;
}
}
What does this code do?
- detailPanel.classList.remove('hidden') makes the panel visible immediately, and the innerHTML assignment shows "Loading details..." so the user gets instant feedback.
- The i parameter tells OMDb to return a single movie's full record instead of a search list.
- The plot=full parameter requests the complete plot summary rather than the short default.
- The same try/catch pattern from searchMovies handles network errors gracefully here too.
The function fetches the data but does not display anything useful yet. Next you will add the HTML template that renders the movie's details inside the panel.
- Inside the try block, find this line:
const movie = await response.json();
- Add the following template directly below it (still inside the try block, before the } catch line):
detailPanel.innerHTML = `
<button id="close-detail">×</button>
<div class="detail-content">
<img src="${movie.Poster !== 'N/A' ? movie.Poster : ''}" alt="${movie.Title}">
<div class="detail-info">
<h2>${movie.Title} (${movie.Year})</h2>
<p class="detail-meta">${movie.Rated} | ${movie.Runtime} | ${movie.Genre}</p>
<p class="detail-plot">${movie.Plot}</p>
<p><strong>Director:</strong> ${movie.Director}</p>
<p><strong>Cast:</strong> ${movie.Actors}</p>
<p><strong>IMDb Rating:</strong> ${movie.imdbRating}/10</p>
</div>
</div>
`;
What does this template do?
This template literal builds the entire detail panel HTML from the API response. It uses the raw property names from the OMDb API (movie.Title, movie.Plot, etc.) because this is a detail fetch, not the search results you cleaned with .map() earlier.
The ternary on movie.Poster prevents a broken image if the poster is 'N/A'. The close button (#close-detail) is included in the template but has no listener yet.
Wire card click listeners in displayResults
The showDetail function exists, but nothing calls it yet. You need to attach a click listener to each movie card so clicking it triggers the detail fetch.
- In app.js, scroll to the displayResults function.
- Find this line inside the forEach loop:
resultsContainer.appendChild(card);
- Add this line directly above it:
card.addEventListener('click', () => showDetail(movie.id));
Why attach listeners inside forEach?
Each card is created dynamically in the loop, so you attach its click listener right there. The arrow function captures movie.id from the current iteration via closure. This is the same pattern React uses when you write onClick={() => handleClick(item.id)} inside a .map().
- Save app.js.
- Refresh your browser and search for "batman".
- Click any movie card.
You should see the detail panel appear from the right side of the page showing the movie poster, title, year, plot, director, cast, and IMDb rating. The X button is visible but does not close the panel yet.
Panel not appearing when you click?
- Open the browser console (F12) and check for errors. A typo in showDetail or movie.id will show up as a ReferenceError.
- Make sure your addEventListener line is inside the forEach loop, before appendChild.
- Confirm that showDetail is defined above displayResults in the file so it is available when the click fires.
Why isn't my detail panel showing when I click a movie card?
Add the close button listener
The panel opens, but there is no way to dismiss it. The close button HTML exists in your template, but it needs an event listener to actually hide the panel.
- In app.js, find the closing of the template literal inside showDetail. Locate this line:
`;
- Add this code directly below it (still inside the try block):
document.getElementById('close-detail').addEventListener('click', () => {
detailPanel.classList.add('hidden');
});
Why query the button after setting innerHTML?
The close button does not exist in the DOM until the template literal is assigned to innerHTML. You must query it after that assignment, not before. This is a key difference from React, where you can attach onClick at the same time you declare the element.
- Save app.js.
Before you test, predict what will happen when you click the X button on the detail panel. Will the panel disappear instantly, animate out, or do nothing?
- Refresh your browser, search for a movie, and click a card to open the detail panel.
- Click the X button in the top-right corner of the detail panel.
The panel disappears instantly. The hidden class sets display: none on the panel, which removes it from view.
X button not closing the panel?
- Make sure the getElementById('close-detail') line is placed after the template literal assignment, not before it.
- Check that the button in your template has id="close-detail" (not class or a different ID).
- Confirm the listener is inside the try block, not after the catch.
Why isn't my close button working on the detail panel?
✔️ Awesome, I've got everything!
Great. Double check you have saved app.js with all three changes: the showDetail function, the card click listener, and the close button listener.
ⓧ I'd like to double check the full code
Your app.js should contain these sections in order. Scroll through your file and confirm each one is present:
- Constants and DOM references (API_KEY, BASE_URL, four getElementById calls).
- debounce function with let timer closure.
- searchMovies function with early return, try/catch, filter/map pipeline.
- displayResults function with card.addEventListener('click', () => showDetail(movie.id)) before each appendChild.
- showDetail function with fetch, detail template, and getElementById('close-detail') listener at the end of the try block.
- debouncedSearch constant and input event listener at the bottom.
You now have a full detail view that fetches a movie's complete record, renders it in a slide-in panel, and dismisses on click. Next up, you will tackle the Secret Mission and add persistent storage so users can save their favourite movies to a watchlist.
Secret mission
Add a Watchlist with localStorage
Your movie search app works great for finding movies. But what happens when you find a movie you want to watch later? Right now, it vanishes the moment you search for something else. Build a watchlist feature that saves your favorite movies to localStorage so they persist across page reloads.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally in your browser with no cloud resources or ongoing costs. Your OMDb API key is free (1,000 requests per day) and stays active indefinitely at no charge.
Resources you used:
- Local project folder containing index.html, style.css, and app.js.
- Browser localStorage data storing your watchlist (if you completed the Secret Mission).
- OMDb API key (free tier, no cost to keep active).
Keep everything running
No action needed. Choose this if you want to keep experimenting with your movie search app.
- Your project files stay on your Desktop, ready to open in the browser anytime.
- Your OMDb API key remains active at no cost (1,000 free requests per day).
- Your watchlist data persists in localStorage until you manually clear it.
Pause - I'll come back to this later
Since everything runs locally, there is nothing to pause or shut down. Your files and API key will be waiting for you when you come back.
- Close the browser tab running your app.
- Your project files and localStorage data remain intact on your machine.
Delete - I don't want to use this again
Remove all project files and stored data from your machine.
- Delete your project folder from the Desktop. On macOS/Linux, open your terminal and run:
cd ~/Desktop
rm -rf movie-search-app
On Windows, open PowerShell and run:
cd ~\Desktop
Remove-Item -Recurse -Force movie-search-app
- Clear your watchlist data from localStorage. Open your browser console (press Cmd+Option+J on macOS or Ctrl+Shift+J on Windows) and run:
localStorage.removeItem('watchlist');
- Your OMDb API key is free and has no associated billing. You can leave it active or simply stop using it.
Nice Work!
Nice Work!
Nice work! You just built a fully functional movie search app from scratch using vanilla JavaScript. No React, no Vue, no frameworks. Just you and the language doing what every framework does behind the scenes.
You've learned how to:
- Built a working movie search app that fetches live data from a REST API, transforms it with array methods (filter, map), and renders dynamic results to the page.
- Wrote a debounce function by hand and understood how closures give it memory between calls.
- Manipulated the DOM directly with createElement, appendChild, innerHTML, and classList to render dynamic content and wire up event listeners on both static and dynamically-created elements.
- Secret Mission: Added persistent storage with localStorage and built a watchlist feature using array methods (some, filter, push).
Ready to quiz yourself?