Build a Movie Search App with React

Build a React app that searches real movies using the TMDB API.

Introduction

30 Second Summary

Every time you search for a movie on a streaming service, a frontend app is sending your query to an API and rendering the results in real time. That seamless experience is built with the same patterns you are about to learn.

In this project, you will build a movie search application with React that connects to a real movie database API. You will type a title, hit search, and see matching movies appear with posters, ratings, and descriptions.

What You'll Build

A responsive movie search app that queries The Movie Database (TMDB) API and displays results with posters, titles, release years, ratings, and overviews.

By the end of this project, you'll have:

  • A React project scaffolded with Vite (the modern industry-standard build tool) with a search bar, movie cards, and a responsive grid layout.
  • A custom hook that handles API calls, loading states, and errors, keeping your data logic cleanly separated from your UI components.
  • Production-style project organization with environment variables protecting your API key, proper error handling, and conditional rendering for loading and empty states.
  • Secret Mission: a favorites feature using localStorage so users can save movies and see them persist across page reloads.

Are there any prerequisites?

You should have basic HTML/CSS knowledge and familiarity with programming fundamentals like variables, functions, and loops. No prior React experience is needed.

Set Up Your TMDB API Key

Our movie search app needs a source of movie data. TMDB (The Movie Database) provides a free API that gives us access to thousands of movies, including posters, ratings, and descriptions.

In this step, you will create a free TMDB account, grab your API Read Access Token, and verify it works by making a test request from your terminal.

In this step, get ready to:
  • Create a free TMDB account.
  • Get your API Read Access Token.
  • Verify the token returns movie data via a test request.
Create a TMDB account and get your API key

TMDB offers free API access for non-commercial projects. You will register, request an API key, and then copy the long-form token that authenticates your requests.

  • Open your browser and go to https://www.themoviedb.org/.
  • Click Join TMDB and create a free account with your email.
  • Confirm your email address by clicking the verification link TMDB sends you.
  • Once logged in, click your profile avatar in the top-right corner.
  • Select Settings from the dropdown menu.
  • In the left sidebar, click API.
  • Click the link to request an API key.
  • Choose Developer when asked about the type of use.
  • Fill in the required fields (for Application URL, you can enter http://localhost since this is a local project).
  • Accept the terms of use and submit.

After your request is approved, the API page displays two values. You need the longer one.

  • On the API page, find the value labeled API Read Access Token.
  • This is a long string starting with ey... (it looks like a JWT).
  • Copy this token and paste it somewhere safe temporarily (a notes app or text file works).

API Read Access Token vs API Key

TMDB shows two credentials on your API page. The shorter one (labeled "API Key") is a 32-character hex string. The longer one (labeled "API Read Access Token") is a JWT starting with ey.

For this project you need the API Read Access Token. It is passed as a Bearer token in request headers, which is the modern authentication pattern used by most APIs today.

Verify your token works

Before building anything, you should confirm the token actually returns movie data. A quick curl command from your terminal will do the job.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type VS Code and press Enter to open it.
  • Open a new terminal inside VS Code by pressing Ctrl+` (macOS and Windows).
  • Test your token by running this command (replace your-api-read-access-token-here with the long token you just copied):
curl --request GET --url "https://api.themoviedb.org/3/search/movie?query=Batman" --header "Authorization: Bearer your-api-read-access-token-here"

You should see a wall of JSON data containing movie results for "Batman" including titles, ratings, and poster paths.

Seeing an error about an invalid API key?

  • Make sure you copied the API Read Access Token (the long one starting with ey), not the shorter "API Key" hex string.
  • Check there are no extra spaces or line breaks in the token you pasted.
  • Confirm you included the word Bearer (with a capital B) before your token in the header.

Still stuck? Help me debug my TMDB API token error.

Understand the API structure

Now that you have confirmed the token works, take a moment to understand the pieces of the API you will use throughout this project.

  • The search endpoint is GET https://api.themoviedb.org/3/search/movie.
  • Authentication uses the Authorization: Bearer <token> header.
  • The query parameter is the search text the user types.
  • Results include title, overview, release_date, vote_average, and poster_path.
  • Poster images are constructed by combining the base URL with the poster path: https://image.tmdb.org/t/p/w500/<poster_path>.

Why Bearer token instead of a query-string key?

Many older APIs pass the key as a URL parameter (like ?api_key=abc123). TMDB uses the Authorization header instead. This is the modern standard because headers are not logged in browser history or server access logs the way URL parameters are.

What about poster images?

The API returns a poster_path like /kqjL17yufvn9OVLyXYpvtyrFfak.jpg. To turn that into a full image URL, prepend the base URL and size: https://image.tmdb.org/t/p/w500. The w500 means 500 pixels wide.

Your API token is ready and you understand how the data comes back. Next up, you will scaffold a brand new React project with Vite and wire the token into your app securely.

Scaffold the React Project with Vite

You have a working TMDB token that returns movie data. Now you need a frontend application to display those results in the browser.

In this step, you will scaffold a React project using Vite, the modern build tool recommended by the React documentation. You will also store your API token safely in an environment variable so it stays out of your source code.

In this step, get ready to:
  • Scaffold a React project with Vite and install dependencies.
  • Store your TMDB token in a secure environment variable file.
  • Start the dev server and clean the template files.
Create the project

Vite is a build tool that scaffolds a ready-to-go React project in seconds. It handles bundling, hot reload, and environment variables out of the box.

  • Check your Node.js version by running this command in your terminal:
node --version

✔️ I see v20.19 or higher

Your Node.js version is compatible with Vite. Continue to the next instruction below.

ⓧ I see an older version

Vite requires Node.js 20.19+ or 22.12+. You need to upgrade before continuing.

  • Visit the official Node.js downloads page at https://nodejs.org.
  • Download and install the latest LTS version.
  • Close and reopen your terminal, then run node --version again to confirm the update.

macOS default Node may be outdated

If you installed Node.js via Homebrew or a system package manager, the default version may be older than what Vite requires. Download directly from nodejs.org to get the latest LTS.

ⓧ Command not found

Node.js is not installed on your machine. You need it to run Vite and React.

  • Visit the official Node.js downloads page at https://nodejs.org.
  • Download and install the latest LTS version (20.19+ or 22.12+).
  • Close and reopen your terminal, then run node --version to confirm the install.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your React project by running this command:
npm create vite@latest movie-search-app -- --template react

What does this command do?

  • npm create vite@latest runs the Vite project scaffolding tool.
  • movie-search-app is the folder name for your project.
  • -- --template react tells Vite to use the React JavaScript template, skipping any interactive prompts.
  • Move into your new project folder and install the dependencies by running these commands:
cd movie-search-app
npm install

You should see a message like "added X packages" confirming the install succeeded.

Seeing permission errors?

On macOS, try prefixing the command with sudo. On Windows, run your terminal as Administrator.

If you see a network error, check your internet connection and try again. Help me fix my npm install error

Set up your environment variable

Your TMDB token is a secret that should never be committed to version control. Vite provides a built-in system for managing secrets through environment variable files.

  • In VS Code, open the movie-search-app folder by clicking File in the top menu bar.
  • Click Open Folder.
  • Select the movie-search-app folder on your Desktop, then click Open.
  • Create a new file in the project root called .env.local by right-clicking in the Explorer sidebar and selecting New File.
  • Add your TMDB token to this file by pasting the following:
VITE_TMDB_TOKEN=your-api-read-access-token-here
  • Replace your-api-read-access-token-here with the actual TMDB API Read Access Token you copied in the previous step (the long string starting with ey...).
  • Save the file.

Why .env.local?

Vite automatically loads files named .env.local and exposes any variable prefixed with VITE_ to your app through import.meta.env.

This file is already in .gitignore by default, so your token stays out of version control.

Don't see .env.local in the sidebar?

Files starting with a dot are hidden by default on some systems. In VS Code's Explorer sidebar, they should still appear. Make sure you created the file at the project root (the same level as package.json), not inside the src folder.

Help me find or create my .env.local file

Start the dev server and clean the template

Vite includes a dev server with hot reload. Every time you save a file, the browser updates instantly without a manual refresh.

  • Open the integrated terminal in VS Code by pressing Ctrl+` (backtick key, same on macOS and Windows).
  • Start the dev server by running:
npm run dev
  • Open http://localhost:5173 in your browser.

You should see the default Vite + React template page with a counter button. You are going to replace this with your own content.

  • In the VS Code Explorer sidebar, open src/App.css.
  • Select all the content (Cmd+A on macOS or Ctrl+A on Windows) and delete it.
  • Save the now-empty src/App.css file.
  • Open src/App.jsx and select all the content.
  • Replace everything with the following code:
function App() {
  return (
    <div className="app">
      <h1>Movie Search</h1>
    </div>
  )
}

export default App

What does this code do?

  • App is a React component. It is a function that returns JSX (HTML-like syntax that React renders to the page).
  • className="app" sets a CSS class on the wrapper div. In JSX, you use className instead of class.
  • export default App makes this component available to other files that import it.
  • Save src/App.jsx.
  • Switch to your browser. You should see just the text "Movie Search" on the page.

Browser still showing the old template?

Make sure you saved both src/App.css and src/App.jsx. Vite's hot reload only triggers on save.

If the page is blank, check the terminal for error messages. A common cause is a missing closing tag or parenthesis in App.jsx.

Help me fix my Vite dev server display issue

✔️ Awesome, I've got everything!

Great. Make sure both files are saved and your browser shows "Movie Search".

ⓧ I'd like to double check the full code

Here is what each file should look like at this point.

VITE_TMDB_TOKEN=your-api-read-access-token-here
function App() {
  return (
    <div className="app">
      <h1>Movie Search</h1>
    </div>
  )
}

export default App

src/App.css should be completely empty (no content at all).

src/main.jsx and src/index.css should remain as Vite generated them. You do not need to edit those files.

Your React project is scaffolded, your API token is safely stored, and hot reload is working. Next up, you will build the SearchBar component so users can type in a movie to search for.

Build the SearchBar Component

Your project is scaffolded and the dev server is running. Now it's time to build the first interactive piece of your movie search UI.

In React, you build interfaces by composing small, focused components. The search bar is how users will tell your app which movie to look up. You'll build it as its own component that manages the text input, then passes the search text up to the parent App when the user submits.

In this step, get ready to:
  • Create a components folder and the SearchBar.jsx file.
  • Build a controlled form component that captures user input.
  • Wire the SearchBar into App.jsx and verify it logs searches to the console.
Create the components folder

React projects organize components in a dedicated folder. This keeps your file structure clean as the app grows.

  • In VS Code, look at the Explorer sidebar on the left.
  • Right-click on the src folder.
  • Click New Folder.
  • Type components and press Enter.
  • Right-click on the new components folder.
  • Click New File.
  • Type SearchBar.jsx and press Enter.

The new file opens automatically in the editor. Your folder structure should now show src/components/SearchBar.jsx.

Don't see the components folder?

Make sure you right-clicked on src specifically, not on the project root. The folder needs to be inside src/ for React imports to work correctly.

Help me create the components folder in the right place.

Implement the SearchBar component

A controlled component is one where React manages the form input's value through state. Every keystroke updates the state, and the state drives what the input displays.

  • In SearchBar.jsx, paste the following code:
import { useState } from 'react'

function SearchBar({ onSearch }) {
  // Track what the user types in the input field
  const [input, setInput] = useState('')

  function handleSubmit(e) {
    // Prevent the browser from refreshing the page
    e.preventDefault()
    const trimmed = input.trim()
    if (trimmed) {
      // Only search if there's actual text
      onSearch(trimmed)
    }
  }

  return (
    <form className="search-form" onSubmit={handleSubmit}>
      <input
        type="text"
        value={input}
        onChange={(e) => setInput(e.target.value)}
        placeholder="Search for a movie..."
      />
      <button type="submit">Search</button>
    </form>
  )
}

export default SearchBar

What does this code do?

  • { onSearch } destructures the props the parent passes in. This is how the SearchBar communicates the search text back up to App.
  • useState('') creates a state variable called input that starts as an empty string. Every keystroke calls setInput to update it.
  • e.preventDefault() stops the browser from reloading the page when the form submits. Without it, you'd lose all your React state on every search.
  • The value={input} on the input element is what makes this a "controlled" component. React owns the value, not the browser.
  • Save SearchBar.jsx.

You won't see a change in the browser yet. The SearchBar component exists, but nothing is using it. The next substep connects it to your App.

Seeing a red error in the terminal?

Check that your file is saved inside src/components/SearchBar.jsx (not directly in src/). Also make sure the file extension is .jsx, not .js.

Help me fix a SearchBar import error in my React project.

✔️ Awesome, I've got everything!

Great. Make sure you've saved the file before moving on.

ⓧ I'd like to double check the full code

import { useState } from 'react'

function SearchBar({ onSearch }) {
  const [input, setInput] = useState('')

  function handleSubmit(e) {
    e.preventDefault()
    const trimmed = input.trim()
    if (trimmed) {
      onSearch(trimmed)
    }
  }

  return (
    <form className="search-form" onSubmit={handleSubmit}>
      <input
        type="text"
        value={input}
        onChange={(e) => setInput(e.target.value)}
        placeholder="Search for a movie..."
      />
      <button type="submit">Search</button>
    </form>
  )
}

export default SearchBar
Wire the SearchBar into App.jsx

Your SearchBar component is ready, but it's isolated. For it to appear on screen, you need to import it into App.jsx and render it inside the JSX.

  • Switch to src/App.jsx in the editor.
  • Replace the entire contents of the file with this code:
import { useState } from 'react'
import SearchBar from './components/SearchBar.jsx'

function App() {
  // Store the current search query
  const [query, setQuery] = useState('')

  function handleSearch(searchText) {
    setQuery(searchText)
    // Temporary log to verify it works
    console.log(searchText)
  }

  return (
    <div className="app">
      <h1>Movie Search</h1>
      <SearchBar onSearch={handleSearch} />
    </div>
  )
}

export default App

What does this code do?

  • useState('') in App creates a query state variable. This will eventually drive the API search.
  • handleSearch is the function that gets called when the user submits the form. It stores the search text in state and logs it so you can verify things work.
  • <SearchBar onSearch={handleSearch} /> renders the SearchBar and passes handleSearch as the onSearch prop. This is how data flows in React. Children call functions passed down from parents.

What are props?

Props are how React components talk to each other. A parent passes data or functions down to a child, like arguments to a function. Here, App passes handleSearch down to SearchBar through the onSearch prop.

  • Save App.jsx.
  • Switch to your browser. You should see the "Movie Search" heading and a text input with a Search button below it.
  • Open your browser's developer console by pressing Cmd+Option+J (macOS) or Ctrl+Shift+J (Windows).
  • Type a movie name (like Batman) into the search field and click Search.
  • You should see the text you typed appear in the console.

Nothing appears in the console?

Make sure you're clicking the Search button (or pressing Enter) to submit, not just typing. The search only fires on form submission.

If you see an error in the browser instead of the search bar, check that the import path in App.jsx matches your file exactly: ./components/SearchBar.jsx. The path is case-sensitive.

Help me debug why my SearchBar isn't working.

✔️ Awesome, I've got everything!

Great. Double check you've saved both files before moving on.

ⓧ I'd like to double check the full code

import { useState } from 'react'
import SearchBar from './components/SearchBar.jsx'

function App() {
  const [query, setQuery] = useState('')

  function handleSearch(searchText) {
    setQuery(searchText)
    console.log(searchText)
  }

  return (
    <div className="app">
      <h1>Movie Search</h1>
      <SearchBar onSearch={handleSearch} />
    </div>
  )
}

export default App

Your first React component is working. It captures input, manages its own state, and communicates with its parent through props. Next up, you'll build a custom hook that takes that search query and fetches real movie data from the TMDB API.

Create the Custom useMovieSearch Hook

Right now, submitting a search updates your query state but nothing actually reaches the TMDB API. The next piece is connecting your app to the database so it can fetch real movie results.

You'll build this connection as a custom React hook. Custom hooks let you extract data-fetching logic out of your components so each component stays focused on rendering UI.

In this step, get ready to:
  • Create the useMovieSearch hook with loading and error state management.
  • Implement the fetch logic using the TMDB search endpoint.
  • Wire the hook into App.jsx and verify the API connection.
Create the hooks folder and file

Just like your components live in src/components/, your custom hooks will live in their own dedicated folder.

  • In the VS Code Explorer sidebar, right-click the src folder and select New Folder.
  • Name it hooks.
  • Right-click the new hooks folder and select New File.
  • Name the file useMovieSearch.js.

Why .js instead of .jsx?

Files that contain JSX (HTML-like syntax) use the .jsx extension. This hook only contains plain JavaScript logic with no JSX, so .js is the correct extension.

Implement the hook with useState and useEffect

This hook will handle every part of the fetch lifecycle: starting the request, storing results, catching errors, and cleaning up cancelled requests.

  • In src/hooks/useMovieSearch.js, add the full hook implementation:
import { useState, useEffect } from 'react'

function useMovieSearch(query) {
  // Three pieces of state to track the fetch lifecycle
  const [movies, setMovies] = useState([])
  const [isLoading, setIsLoading] = useState(false)
  const [error, setError] = useState(null)

  useEffect(() => {
    // If query is empty, reset and skip the fetch
    if (!query) {
      setMovies([])
      setError(null)
      return
    }

    // AbortController lets us cancel the request if query changes mid-fetch
    const controller = new AbortController()

    async function fetchMovies() {
      setIsLoading(true)
      setError(null)

      try {
        const response = await fetch(
          `https://api.themoviedb.org/3/search/movie?query=${encodeURIComponent(query)}&language=en-US&page=1`,
          {
            headers: {
              Authorization: `Bearer ${import.meta.env.VITE_TMDB_TOKEN}`,
            },
            signal: controller.signal,
          }
        )

        if (!response.ok) {
          throw new Error(`API error: ${response.status}`)
        }

        const data = await response.json()
        setMovies(data.results)
      } catch (err) {
        if (err.name !== 'AbortError') {
          setError(err.message)
        }
      } finally {
        setIsLoading(false)
      }
    }

    fetchMovies()

    // Cleanup: abort the request if this effect re-runs
    return () => {
      controller.abort()
    }
  }, [query])

  return { movies, isLoading, error }
}

export default useMovieSearch

What does this code do?

This hook manages three states: movies (the results array), isLoading (whether a fetch is in progress), and error (any error message from a failed request).

The useEffect watches the query value. Every time query changes, it fires a new fetch to the TMDB search endpoint with your token in the Authorization header.

The AbortController prevents race conditions. If a user types quickly and the query changes before the previous fetch finishes, the old request gets cancelled so stale results never overwrite fresh ones.

  • Save src/hooks/useMovieSearch.js.

You won't see any change in the browser yet because nothing imports this hook. The next substep connects it to your App.

Seeing a red underline in VS Code?

If VS Code shows a warning about import.meta.env, that's normal. The editor doesn't always recognize Vite's environment variable syntax, but the code runs correctly at build time.

If you see an error about a missing react import, make sure the first line of your file is import { useState, useEffect } from 'react'.

Help me debug my useMovieSearch.js hook file

Connect the hook to App.jsx

Now you'll import the hook into your App component and use it to fetch real data from TMDB whenever the user submits a search.

  • Replace the entire contents of src/App.jsx with the following updated code:
import { useState } from 'react'
import SearchBar from './components/SearchBar.jsx'
import useMovieSearch from './hooks/useMovieSearch.js'

function App() {
  const [query, setQuery] = useState('')
  // Call the custom hook - it fetches whenever query changes
  const { movies, isLoading, error } = useMovieSearch(query)

  function handleSearch(searchText) {
    setQuery(searchText)
  }

  return (
    <div className="app">
      <h1>Movie Search</h1>
      <SearchBar onSearch={handleSearch} />
      <p>{movies.length} results</p>
    </div>
  )
}

export default App

What changed in App.jsx?

Three things changed. First, you imported useMovieSearch from your new hooks folder.

Second, you call useMovieSearch(query) and destructure its return value into movies, isLoading, and error. Every time query changes (when the user submits a search), the hook automatically re-fetches.

Third, the console.log is gone because you no longer need it for debugging. Instead, {movies.length} results gives you a quick way to confirm the API is responding.

  • Save src/App.jsx.
  • Switch to your browser (the page should auto-refresh).
  • Type Batman in the search field and click Search.
  • You should see something like 20 results appear below the search bar.

Seeing 0 results or an error?

If you see 0 results after searching, open your browser's DevTools console (Cmd+Option+J on macOS or Ctrl+Shift+J on Windows). Look for a 401 error, which means your token is not being read correctly.

Check that your .env.local file is in the project root (the same level as package.json), not inside src/. Also confirm your variable name is exactly VITE_TMDB_TOKEN with no typos.

If you changed .env.local, you need to restart the dev server. Press Ctrl+C in the terminal, then run npm run dev again.

Help me debug my API connection

✔️ Awesome, I've got everything!

Great. Double check you've saved both files before continuing.

ⓧ I'd like to double check the full code

Here's what your two updated files should look like at this point:

import { useState, useEffect } from 'react'

function useMovieSearch(query) {
  const [movies, setMovies] = useState([])
  const [isLoading, setIsLoading] = useState(false)
  const [error, setError] = useState(null)

  useEffect(() => {
    if (!query) {
      setMovies([])
      setError(null)
      return
    }

    const controller = new AbortController()

    async function fetchMovies() {
      setIsLoading(true)
      setError(null)

      try {
        const response = await fetch(
          `https://api.themoviedb.org/3/search/movie?query=${encodeURIComponent(query)}&language=en-US&page=1`,
          {
            headers: {
              Authorization: `Bearer ${import.meta.env.VITE_TMDB_TOKEN}`,
            },
            signal: controller.signal,
          }
        )

        if (!response.ok) {
          throw new Error(`API error: ${response.status}`)
        }

        const data = await response.json()
        setMovies(data.results)
      } catch (err) {
        if (err.name !== 'AbortError') {
          setError(err.message)
        }
      } finally {
        setIsLoading(false)
      }
    }

    fetchMovies()

    return () => {
      controller.abort()
    }
  }, [query])

  return { movies, isLoading, error }
}

export default useMovieSearch
import { useState } from 'react'
import SearchBar from './components/SearchBar.jsx'
import useMovieSearch from './hooks/useMovieSearch.js'

function App() {
  const [query, setQuery] = useState('')
  const { movies, isLoading, error } = useMovieSearch(query)

  function handleSearch(searchText) {
    setQuery(searchText)
  }

  return (
    <div className="app">
      <h1>Movie Search</h1>
      <SearchBar onSearch={handleSearch} />
      <p>{movies.length} results</p>
    </div>
  )
}

export default App

Why a custom hook instead of fetching directly in App?

Putting fetch logic directly inside your component works, but it tangles data concerns with rendering concerns. As your app grows, that tangle becomes hard to test and hard to reuse.

A custom hook gives you a clean contract: pass in a query, get back movies, loading state, and errors. Any component in your app could use the same hook without duplicating the fetch code.

Your app now talks to a real API and displays how many movies matched your search. Next up, you'll build the MovieCard and MovieList components to render those results as a polished grid of movie posters, titles, and ratings.

Build the MovieCard and MovieList Components

Your custom hook is fetching real movie data from TMDB, but right now you can only see a count of results. It is time to build the components that actually display those movies to the user.

In this step, you will create two new React components. MovieCard handles the visual presentation of a single movie. MovieList manages which state to show (loading, error, or the grid of cards) and renders the full collection.

In this step, get ready to:
  • Create a MovieCard component that displays a movie's poster, title, year, rating, and overview.
  • Create a MovieList component that conditionally renders loading, error, empty, or results states.
  • Assemble the full search flow end-to-end in App.jsx.
Create the MovieCard component

Each search result from TMDB includes a title, poster image path, release date, rating, and overview. The MovieCard component takes a single movie object and renders all of this information in a card layout.

  • Create a new file called MovieCard.jsx inside the src/components/ folder.
  • Add the following code to MovieCard.jsx:
function MovieCard({ movie }) {
  // Build the full image URL, or null if no poster exists
  const imageUrl = movie.poster_path
    ? `https://image.tmdb.org/t/p/w500${movie.poster_path}`
    : null

  // Extract just the year from the release date string
  const year = movie.release_date
    ? movie.release_date.split('-')[0]
    : 'N/A'

  return (
    <div className="movie-card">
      {imageUrl ? (
        <img src={imageUrl} alt={`${movie.title} poster`} />
      ) : (
        <div className="movie-card-placeholder">No Image</div>
      )}
      <div className="movie-card-info">
        <h3>{movie.title}</h3>
        <div className="movie-card-meta">
          <span>{year}</span>
          <span className="movie-card-rating">★ {movie.vote_average.toFixed(1)}</span>
        </div>
        <p className="movie-card-overview">{movie.overview}</p>
      </div>
    </div>
  )
}

export default MovieCard

What does this code do?

This component receives a movie object as a prop and displays its details in a card format.

  • The imageUrl variable constructs the full poster URL by combining TMDB's image base URL with the movie's poster_path. If no poster exists, it falls back to null.
  • The year variable extracts just the four-digit year from a date string like 2022-03-01 by splitting on the hyphen and taking the first piece.
  • The ternary expression in the JSX handles conditional rendering. If imageUrl exists, show the poster image. Otherwise, show a placeholder div.
  • The .toFixed(1) method formats the rating to one decimal place (e.g. 7.8 instead of 7.823).
  • Save MovieCard.jsx.

You will not see a change in the browser yet. The next component (MovieList) will use MovieCard to display the results.

Create the MovieList component

A search can be in several states: loading, errored, empty, or showing results. The MovieList component handles all of these possibilities with conditional rendering, showing the right message or content for each state.

  • Create a new file called MovieList.jsx inside the src/components/ folder.
  • Add the following code to MovieList.jsx:
import MovieCard from './MovieCard.jsx'

function MovieList({ movies, isLoading, error }) {
  // Show a loading indicator while fetching
  if (isLoading) {
    return <p className="status-message">Searching for movies...</p>
  }

  // Show the error message if the fetch failed
  if (error) {
    return <p className="error-message">Something went wrong: {error}</p>
  }

  // Show a prompt if there are no results yet
  if (movies.length === 0) {
    return <p className="status-message">Search for a movie to get started!</p>
  }

  // Render the grid of movie cards
  return (
    <div className="movie-grid">
      {movies.map((movie) => (
        <MovieCard key={movie.id} movie={movie} />
      ))}
    </div>
  )
}

export default MovieList

What does this code do?

MovieList acts as a state router. It checks the current state of the search and returns the appropriate UI for each situation.

  • The early if returns are a common React pattern called guard clauses. Each one handles a specific edge case and exits early so the main rendering logic stays clean.
  • The .map() method loops through the movies array and creates a MovieCard for each one. The key={movie.id} prop helps React efficiently update the list when results change.
  • Save MovieList.jsx.

You will not see a change in the browser yet. The next substep wires MovieList into your App component.

Assemble everything in App.jsx

With both display components ready, you can now replace the temporary results count with the real MovieList. You will also add proper semantic HTML structure with a header, main, and footer.

  • Replace the entire contents of src/App.jsx with the following code:
import { useState } from 'react'
import SearchBar from './components/SearchBar.jsx'
import MovieList from './components/MovieList.jsx'
import useMovieSearch from './hooks/useMovieSearch.js'
import './App.css'

function App() {
  const [query, setQuery] = useState('')
  const { movies, isLoading, error } = useMovieSearch(query)

  function handleSearch(searchText) {
    setQuery(searchText)
  }

  return (
    <div className="app">
      <header className="app-header">
        <h1>Movie Search</h1>
        <p>Search for any movie using The Movie Database</p>
      </header>
      <main className="app-main">
        <SearchBar onSearch={handleSearch} />
        <MovieList movies={movies} isLoading={isLoading} error={error} />
      </main>
      <footer className="app-footer">
        <p>This product uses the TMDB API but is not endorsed or certified by TMDB.</p>
      </footer>
    </div>
  )
}

export default App

What changed in App.jsx?

  • You added the MovieList import and the ./App.css import (the CSS file is empty for now but will be used in the next step).
  • The temporary <p>{movies.length} results</p> line is replaced by <MovieList>, which receives the three values from your hook.
  • The layout now uses semantic HTML: <header>, <main>, and <footer>. This gives the page proper structure for accessibility and styling.
  • The footer includes the required TMDB attribution text.
  • Save App.jsx.
  • Return to your browser at http://localhost:5173. You should see the text "Search for a movie to get started!" displayed below the search bar.
  • Type a movie name (like Batman) into the search bar and press Enter. You should see movie titles, years, ratings, and overviews appear. Poster images will show up as well (though unstyled for now).

Not seeing any results?

  • Check your browser's developer console for errors. If you see a 401 status, your TMDB token in .env.local may be incorrect.
  • Make sure you saved all three files: MovieCard.jsx, MovieList.jsx, and App.jsx.
  • Verify that MovieCard.jsx and MovieList.jsx are in the src/components/ folder, not in src/ directly.

Still stuck? Help me debug why my MovieList is not rendering search results.

✔️ Awesome, I've got everything!

Great. Double check you have saved all your files before moving on.

ⓧ I'd like to double check the full code

Here are the three files you created or modified in this step.

function MovieCard({ movie }) {
  const imageUrl = movie.poster_path
    ? `https://image.tmdb.org/t/p/w500${movie.poster_path}`
    : null

  const year = movie.release_date
    ? movie.release_date.split('-')[0]
    : 'N/A'

  return (
    <div className="movie-card">
      {imageUrl ? (
        <img src={imageUrl} alt={`${movie.title} poster`} />
      ) : (
        <div className="movie-card-placeholder">No Image</div>
      )}
      <div className="movie-card-info">
        <h3>{movie.title}</h3>
        <div className="movie-card-meta">
          <span>{year}</span>
          <span className="movie-card-rating">★ {movie.vote_average.toFixed(1)}</span>
        </div>
        <p className="movie-card-overview">{movie.overview}</p>
      </div>
    </div>
  )
}

export default MovieCard
import MovieCard from './MovieCard.jsx'

function MovieList({ movies, isLoading, error }) {
  if (isLoading) {
    return <p className="status-message">Searching for movies...</p>
  }

  if (error) {
    return <p className="error-message">Something went wrong: {error}</p>
  }

  if (movies.length === 0) {
    return <p className="status-message">Search for a movie to get started!</p>
  }

  return (
    <div className="movie-grid">
      {movies.map((movie) => (
        <MovieCard key={movie.id} movie={movie} />
      ))}
    </div>
  )
}

export default MovieList
import { useState } from 'react'
import SearchBar from './components/SearchBar.jsx'
import MovieList from './components/MovieList.jsx'
import useMovieSearch from './hooks/useMovieSearch.js'
import './App.css'

function App() {
  const [query, setQuery] = useState('')
  const { movies, isLoading, error } = useMovieSearch(query)

  function handleSearch(searchText) {
    setQuery(searchText)
  }

  return (
    <div className="app">
      <header className="app-header">
        <h1>Movie Search</h1>
        <p>Search for any movie using The Movie Database</p>
      </header>
      <main className="app-main">
        <SearchBar onSearch={handleSearch} />
        <MovieList movies={movies} isLoading={isLoading} error={error} />
      </main>
      <footer className="app-footer">
        <p>This product uses the TMDB API but is not endorsed or certified by TMDB.</p>
      </footer>
    </div>
  )
}

export default App

Your app now fetches real movie data and displays it with titles, ratings, and posters. Next up, you will style everything with responsive CSS so it looks polished on any screen size.

Style the App with Responsive CSS

Your movie search app works. You can type a query, hit search, and see real movie data appear on screen.

Right now it looks like unstyled HTML. In this step, you will write CSS that transforms the raw content into a polished, card-based layout that adapts to any screen size.

In this step, get ready to:
  • Style the page layout, header, and search form.
  • Build a responsive movie card grid with hover effects.
  • Add a mobile breakpoint and test responsiveness in DevTools.
Write the layout and search form styles

The app container needs a max width so content doesn't stretch across ultra-wide monitors. The search form needs a flexbox layout to keep the input and button side by side.

  • In VS Code, open src/App.css (the file is currently empty).
  • Add the following styles for the page layout and search form:
/* Page layout and centering */
.app {
  max-width: 1200px;
  margin: 0 auto;
  padding: 0 1rem;
}

/* Header styling */
.app-header {
  text-align: center;
  padding: 2rem 0;
}

.app-header h1 {
  font-size: 2.5rem;
  color: #1a1a2e;
  margin-bottom: 0.5rem;
}

.app-header p {
  color: #666;
  font-size: 1.1rem;
}

/* Main content area */
.app-main {
  padding: 1rem 0;
}

/* Footer styling */
.app-footer {
  text-align: center;
  padding: 2rem 0;
  color: #999;
  font-size: 0.85rem;
}

/* Search form - flexbox layout */
.search-form {
  display: flex;
  gap: 0.5rem;
  max-width: 600px;
  margin: 0 auto 2rem auto;
}

.search-form input {
  flex: 1;
  padding: 0.75rem 1rem;
  font-size: 1rem;
  border: 2px solid #ddd;
  border-radius: 8px;
  outline: none;
  transition: border-color 0.2s;
}

.search-form input:focus {
  border-color: #4a90d9;
}

.search-form button {
  padding: 0.75rem 1.5rem;
  font-size: 1rem;
  background-color: #4a90d9;
  color: white;
  border: none;
  border-radius: 8px;
  cursor: pointer;
  transition: background-color 0.2s;
}

.search-form button:hover {
  background-color: #357abd;
}

What does this code do?

The .app container uses max-width: 1200px with margin: 0 auto to center itself on wide screens while keeping content readable.

The search form uses display: flex to place the input and button side by side. The flex: 1 on the input makes it fill all available space, pushing the button to the right.

  • Save src/App.css.
  • Switch back to your browser. You should see the heading centered, the search bar neatly contained in the middle, and the button styled blue with rounded corners.

Still seeing unstyled content?

Confirm that src/App.jsx has import './App.css' near the top. This import was added in the previous step. Without it, Vite cannot inject the styles.

Also check that your CSS file is saved at src/App.css (not in the project root or in the components folder).

Help me debug why my CSS styles aren't showing up in my Vite React app.

Style the movie grid and cards

CSS Grid handles the responsive column layout automatically. Combined with card styling and hover effects, this turns raw data into an engaging visual grid.

  • In src/App.css, add the following styles below the search form button hover rule:
/* Responsive movie grid */
.movie-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
  gap: 1.5rem;
  padding: 1rem 0;
}

/* Card container with hover effect */
.movie-card {
  background: white;
  border-radius: 12px;
  overflow: hidden;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
  transition: transform 0.2s, box-shadow 0.2s;
}

.movie-card:hover {
  transform: translateY(-4px);
  box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
}

/* Poster image fills card width */
.movie-card img {
  width: 100%;
  height: 375px;
  object-fit: cover;
}

/* Placeholder when no poster exists */
.movie-card-placeholder {
  width: 100%;
  height: 375px;
  background: #e0e0e0;
  display: flex;
  align-items: center;
  justify-content: center;
  color: #999;
  font-size: 1.2rem;
}

/* Card text content */
.movie-card-info {
  padding: 1rem;
}

.movie-card-info h3 {
  font-size: 1.1rem;
  margin-bottom: 0.25rem;
  color: #1a1a2e;
}

.movie-card-meta {
  display: flex;
  gap: 1rem;
  margin-bottom: 0.5rem;
  font-size: 0.9rem;
  color: #666;
}

.movie-card-rating {
  color: #f5c518;
  font-weight: bold;
}

/* Truncate overview to 3 lines */
.movie-card-overview {
  font-size: 0.85rem;
  color: #555;
  line-height: 1.4;
  display: -webkit-box;
  -webkit-line-clamp: 3;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

/* Loading and empty state messages */
.status-message {
  text-align: center;
  padding: 3rem 1rem;
  color: #666;
  font-size: 1.1rem;
}

/* Error state message */
.error-message {
  text-align: center;
  padding: 3rem 1rem;
  color: #e74c3c;
  font-size: 1.1rem;
}

What does this code do?

The grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)) rule is the key line. It tells the browser to create as many columns as fit, each at least 250px wide. On a wide screen you get 4 columns. On a narrow screen, fewer columns appear automatically.

The -webkit-line-clamp: 3 property on the overview text clips long descriptions at 3 lines with an ellipsis, keeping every card the same visual height.

The hover effect uses transform: translateY(-4px) to lift the card slightly when the user mouses over it. This subtle motion gives the interface a tactile feel.

  • Save src/App.css.
  • In your browser, search for a movie (try "Batman"). You should see a grid of white cards with poster images, titles, yellow star ratings, and truncated overviews.
  • Hover over a card. It should lift up slightly with an enhanced shadow.

Cards appearing but not in a grid?

Make sure the class name in your CSS (.movie-grid) matches the class name in MovieList.jsx (className="movie-grid"). CSS class names are case-sensitive.

If cards stack vertically in a single column, check that your grid-template-columns property is spelled correctly and uses repeat(auto-fill, minmax(250px, 1fr)).

Help me fix my CSS Grid layout for movie cards.

Add a mobile breakpoint and test responsiveness

The grid already adapts to screen width, but the header font and search form need additional help on small screens. A media query handles those edge cases.

  • At the bottom of src/App.css, add this media query:
/* Mobile breakpoint */
@media (max-width: 600px) {
  .app-header h1 {
    font-size: 1.8rem;
  }

  .search-form {
    flex-direction: column;
  }

  .movie-grid {
    grid-template-columns: 1fr;
  }
}

What does this code do?

When the browser window is 600px or narrower, these overrides kick in. The heading shrinks so it does not overflow. The search form switches from a horizontal row to a vertical stack so the button sits below the input. The grid forces a single column so cards fill the full width.

  • Save src/App.css.
  • In your browser, open DevTools by pressing Cmd+Option+I (macOS) or Ctrl+Shift+I (Windows).
  • Click the device toolbar toggle icon (a small phone/tablet icon in the top-left of the DevTools panel) to enable responsive mode.
  • Drag the viewport width from wide to narrow. You should see the grid shift from multiple columns down to a single column at 600px.
  • Confirm the search bar stacks vertically and the heading text shrinks on the narrow viewport.

Media query not taking effect?

Check that the @media block is placed at the very bottom of your CSS file, after all other rules. If another rule appears after it with higher specificity, the override may not apply.

Also verify your browser viewport is actually below 600px. The DevTools responsive mode shows the current pixel width in the toolbar.

Help me debug why my CSS media query isn't working.

✔️ Awesome, I've got everything!

Great. Double check you have saved src/App.css and your browser reflects all the styles.

ⓧ I'd like to double check the full code

Here is the complete src/App.css file. Compare yours against this:

.app {
  max-width: 1200px;
  margin: 0 auto;
  padding: 0 1rem;
}

.app-header {
  text-align: center;
  padding: 2rem 0;
}

.app-header h1 {
  font-size: 2.5rem;
  color: #1a1a2e;
  margin-bottom: 0.5rem;
}

.app-header p {
  color: #666;
  font-size: 1.1rem;
}

.app-main {
  padding: 1rem 0;
}

.app-footer {
  text-align: center;
  padding: 2rem 0;
  color: #999;
  font-size: 0.85rem;
}

.search-form {
  display: flex;
  gap: 0.5rem;
  max-width: 600px;
  margin: 0 auto 2rem auto;
}

.search-form input {
  flex: 1;
  padding: 0.75rem 1rem;
  font-size: 1rem;
  border: 2px solid #ddd;
  border-radius: 8px;
  outline: none;
  transition: border-color 0.2s;
}

.search-form input:focus {
  border-color: #4a90d9;
}

.search-form button {
  padding: 0.75rem 1.5rem;
  font-size: 1rem;
  background-color: #4a90d9;
  color: white;
  border: none;
  border-radius: 8px;
  cursor: pointer;
  transition: background-color 0.2s;
}

.search-form button:hover {
  background-color: #357abd;
}

.movie-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
  gap: 1.5rem;
  padding: 1rem 0;
}

.movie-card {
  background: white;
  border-radius: 12px;
  overflow: hidden;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
  transition: transform 0.2s, box-shadow 0.2s;
}

.movie-card:hover {
  transform: translateY(-4px);
  box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
}

.movie-card img {
  width: 100%;
  height: 375px;
  object-fit: cover;
}

.movie-card-placeholder {
  width: 100%;
  height: 375px;
  background: #e0e0e0;
  display: flex;
  align-items: center;
  justify-content: center;
  color: #999;
  font-size: 1.2rem;
}

.movie-card-info {
  padding: 1rem;
}

.movie-card-info h3 {
  font-size: 1.1rem;
  margin-bottom: 0.25rem;
  color: #1a1a2e;
}

.movie-card-meta {
  display: flex;
  gap: 1rem;
  margin-bottom: 0.5rem;
  font-size: 0.9rem;
  color: #666;
}

.movie-card-rating {
  color: #f5c518;
  font-weight: bold;
}

.movie-card-overview {
  font-size: 0.85rem;
  color: #555;
  line-height: 1.4;
  display: -webkit-box;
  -webkit-line-clamp: 3;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

.status-message {
  text-align: center;
  padding: 3rem 1rem;
  color: #666;
  font-size: 1.1rem;
}

.error-message {
  text-align: center;
  padding: 3rem 1rem;
  color: #e74c3c;
  font-size: 1.1rem;
}

@media (max-width: 600px) {
  .app-header h1 {
    font-size: 1.8rem;
  }

  .search-form {
    flex-direction: column;
  }

  .movie-grid {
    grid-template-columns: 1fr;
  }
}

Your movie search app is fully styled and responsive. You have built a complete React application from scratch. Next up, a secret mission that adds even more polish.

Secret mission

Add a Favorites Feature with localStorage

Your movie search app works great, but what happens when you find a movie you love? Right now, it disappears the moment you search for something else. Build a Favorites system that lets users save movies and persist them across browser sessions using localStorage.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • The movie-search-app project folder on your Desktop.
  • The Vite dev server running at http://localhost:5173.
  • Your TMDB account and API Read Access Token (free, no ongoing charges).

Keep everything running

No action needed. Choose this if you're still actively building or want to keep experimenting with the app.

  • Your dev server continues running at http://localhost:5173.
  • Your movie-search-app folder stays on your Desktop.
  • Your TMDB account remains active with no cost.
  • Node.js and npm remain installed on your machine.

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 resume later.

  • Switch to the terminal where the Vite dev server is running.
  • Press Ctrl+C to stop the server.
  • When you want to resume, open a terminal in the movie-search-app folder and run npm run dev again.

Delete - I don't want to use this again

Remove all project files and stop the running process. This cannot be undone.

  • Switch to the terminal where the Vite dev server is running.
  • Press Ctrl+C to stop the server.
  • Delete the project folder by running this command (macOS/Linux):
cd ~/Desktop
rm -rf movie-search-app

On Windows, run this instead:

cd ~/Desktop
rmdir /s /q movie-search-app
  • Confirm the folder is gone by running ls (macOS/Linux) or dir (Windows) and checking that movie-search-app no longer appears.

Nice Work!

Nice Work!

You just built a fully functional movie search app with React that connects to a real-world API and displays results with posters, ratings, and descriptions.

You've learned how to:

  • Built a React project from scratch using Vite and created reusable components (SearchBar, MovieCard, MovieList) that communicate through props.
  • Created a custom hook (useMovieSearch) to separate data-fetching logic from UI, handling loading, error, and empty states gracefully.
  • Connected to the TMDB API with Bearer token authentication and used environment variables to keep your API secrets out of source code.
  • Added a favorites feature with localStorage persistence so saved movies survive page reloads.

Ready to quiz yourself?