Build a Movie Browser with Next.js

Build a movie browsing app using Next.js 16 App Router and TMDB API.

Introduction

30 Second Summary

Every time you scroll through a grid of movies on a streaming platform, you're looking at a web app that fetches data from an API and renders it dynamically. That same pattern powers thousands of production apps.

In this project, you will build a movie browsing application that pulls real data from a public movie database, displays it in a responsive grid, and lets users click into detail pages or search for movies by title.

What You'll Build

A movie browser powered by the TMDB API, built with Next.js 16's App Router and Server Components for fast, server-side data fetching.

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

  • A Next.js 16 project using the App Router with TypeScript and Tailwind CSS for styling.
  • A home page Server Component that fetches popular movies from a real API and displays them in a responsive grid.
  • Dynamic detail pages and a Client Component search bar that demonstrates the Server/Client Component boundary.
  • Secret Mission: Add genre filtering with parallel data fetching from multiple API endpoints.

Are there any prerequisites?

This project assumes you're comfortable with React fundamentals like components, props, state, and hooks. Basic HTML, CSS, and JavaScript knowledge is also expected.

No prior Next.js experience is needed. You'll learn the App Router and Server Components from scratch.

Set Up the Project

Your movie browser fetches real movie data from TMDB (The Movie Database), a popular free API that powers thousands of apps.

In this step, you'll create a TMDB account, scaffold a fresh Next.js 16 project, and wire up your API token so the app can talk to TMDB's servers.

In this step, get ready to:
  • Create a TMDB account and get your API Read Access Token.
  • Scaffold a Next.js 16 project with TypeScript and Tailwind CSS.
  • Configure your environment variable and verify the dev server runs.
Create a TMDB account and get your API key

TMDB provides free access to a massive database of movie info, posters, and backdrops. You need an account and an API token to authenticate your requests.

  • Open https://www.themoviedb.org/signup in your browser and create a free account.
  • Once logged in, click your profile icon 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 and choose the Developer option.
  • Fill in the required fields (a short description of your app is fine) and submit.

What should I see?

After your request is approved, the API page shows two values: an API Key and an API Read Access Token. You want the API Read Access Token (the longer string). This is the Bearer token your app uses to authenticate.

  • Copy the API Read Access Token and save it somewhere safe (a notes app or text file works).

Keep your token private

Never commit this token to a public repository. You'll store it in a .env.local file that stays on your machine only.

Scaffold the Next.js project

Next.js 16 requires Node.js version 20.9.0 or higher. Let's confirm your version before scaffolding.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal (macOS) or PowerShell (Windows) and press Enter to open it.
  • Check your Node.js version by running:
node --version

✔️ I see version 20.9 or higher

You're all set. Move on to the next instruction below.

ⓧ I see an older version

You need Node.js 20.9.0+ for Next.js 16. Download the latest LTS version from https://nodejs.org and run the installer.

After installing, close and reopen your terminal, then run node --version again to confirm.

ⓧ Command not found

Node.js is not installed. Download the latest LTS version from https://nodejs.org and run the installer.

After installing, close and reopen your terminal, then run node --version again to confirm you see 20.9.0 or higher.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Scaffold your Next.js project by running:
npx create-next-app@latest movie-browser

What will this command ask?

The CLI will prompt you to choose your project settings. Select Yes, use recommended defaults when prompted. This enables TypeScript, ESLint, Tailwind CSS, and the App Router automatically.

  • Select Yes, use recommended defaults when the prompt appears.
  • Wait for the installation to finish. You'll see a success message once all packages are installed.
  • Move into your new project folder by running:
cd movie-browser

Seeing a network error or timeout?

The scaffold downloads packages from the npm registry, so you need an active internet connection. If the command hangs or fails, check your connection and try again.

help me troubleshoot my create-next-app installation error

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type VS Code and press Enter to open it.
  • In VS Code, click File in the top menu bar.
  • Click Open Folder.
  • Navigate to your Desktop, select the movie-browser folder, and click Open.

What should I see?

The VS Code Explorer sidebar shows your project structure. You'll see folders like app/, public/, and files like package.json and next.config.ts.

Configure the environment variable and verify

Your app needs the TMDB token to authenticate API requests. Storing it in a .env.local file keeps it out of your code and away from version control.

  • In the VS Code Explorer sidebar, right-click in the empty space below the file list.
  • Select New File.
  • Name the file .env.local and press Enter.
  • Paste the following into the file:
TMDB_API_TOKEN=your-api-read-access-token-here
  • Replace your-api-read-access-token-here with the API Read Access Token you copied from TMDB.

What does this line do?

TMDB_API_TOKEN is the environment variable name your app reads at runtime. Next.js automatically loads .env.local files on the server side, making the token available to Server Components without exposing it to the browser.

  • Save the file with Cmd+S (macOS) or Ctrl+S (Windows).

✔️ Awesome, I've got everything!

Great. Make sure you've saved .env.local with your real token pasted in.

ⓧ I'd like to double check the full code

Your .env.local file should contain exactly one line (with your real token replacing the placeholder):

TMDB_API_TOKEN=your-api-read-access-token-here

Now let's start the development server to confirm everything works.

  • Open the VS Code integrated terminal by pressing Ctrl+` (backtick key, below Esc).
  • Start the dev server by running:
npm run dev
  • Open your browser and go to http://localhost:3000.

What should I see?

You should see the default Next.js welcome page with the Next.js logo and some starter text. This confirms your project is scaffolded correctly and the dev server is running.

Page not loading at localhost:3000?

Make sure the terminal shows a message confirming the server started (something like "Ready" with a local URL). If you see an error, check that you're running the command from inside the movie-browser folder.

help me fix my Next.js dev server

Your environment is ready and your app is running. Next up, you'll replace that welcome page with a grid of real movie data fetched from TMDB.

Build the Movie Grid

Your project is scaffolded and the dev server is running. Now it's time to connect to the TMDB API and display real movie data on the page.

In Next.js 16, pages are Server Components by default. This means your page can be an async function that fetches data directly on the server. No useEffect, no loading spinners for the initial render, no API tokens leaking to the browser.

In this step, get ready to:
  • Create a reusable TMDB fetch utility with TypeScript interfaces.
  • Build the home page as an async Server Component that renders a movie grid.
  • Update the root layout with a navigation header.
Create a reusable fetch utility

Before building the page, you need a helper function that handles authentication with the TMDB API. Centralizing this logic means every future API call reuses the same auth headers and error handling.

  • In the VS Code Explorer sidebar, right-click the app folder and select New Folder.
  • Name the folder lib.
  • Right-click the new lib folder and select New File.
  • Name the file tmdb.ts.
  • Add the fetch utility function by pasting this code into app/lib/tmdb.ts:
const TMDB_BASE_URL = "https://api.themoviedb.org/3";

export async function fetchFromTMDB(endpoint: string) {
  const response = await fetch(`${TMDB_BASE_URL}${endpoint}`, {
    headers: {
      Authorization: `Bearer ${process.env.TMDB_API_TOKEN}`,
      "Content-Type": "application/json",
    },
  });

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

  return response.json();
}

What does this code do?

The fetchFromTMDB function takes an API endpoint string and makes an authenticated request to TMDB. It reads the Bearer token from process.env.TMDB_API_TOKEN (your .env.local file) and attaches it as an Authorization header.

Because this runs in a Server Component, the token never reaches the browser. If the response fails, the function throws an error with the HTTP status code for easier debugging.

  • Now add the TypeScript interfaces below the function in the same file. These define the shape of the data TMDB returns:
export interface Movie {
  id: number;
  title: string;
  overview: string;
  poster_path: string | null;
  backdrop_path: string | null;
  release_date: string;
  vote_average: number;
  genre_ids?: number[];
}

export interface MovieDetails {
  id: number;
  title: string;
  overview: string;
  poster_path: string | null;
  backdrop_path: string | null;
  release_date: string;
  vote_average: number;
  runtime: number;
  tagline: string;
  genres: { id: number; name: string }[];
}

export interface Genre {
  id: number;
  name: string;
}

What are these interfaces for?

Each interface describes the structure of a different TMDB response. Movie is for the list view (popular movies). MovieDetails is for individual movie pages (includes extra fields like runtime and genres). Genre is for genre filtering later.

TypeScript uses these to catch typos and give you autocomplete when accessing movie properties.

  • Save app/lib/tmdb.ts.

You won't see a change in the browser yet. The next substep uses this utility to fetch and display movies.

✔️ Awesome, I've got everything!

Great. Double check you've saved app/lib/tmdb.ts before moving on.

ⓧ I'd like to double check the full code

const TMDB_BASE_URL = "https://api.themoviedb.org/3";

export async function fetchFromTMDB(endpoint: string) {
  const response = await fetch(`${TMDB_BASE_URL}${endpoint}`, {
    headers: {
      Authorization: `Bearer ${process.env.TMDB_API_TOKEN}`,
      "Content-Type": "application/json",
    },
  });

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

  return response.json();
}

export interface Movie {
  id: number;
  title: string;
  overview: string;
  poster_path: string | null;
  backdrop_path: string | null;
  release_date: string;
  vote_average: number;
  genre_ids?: number[];
}

export interface MovieDetails {
  id: number;
  title: string;
  overview: string;
  poster_path: string | null;
  backdrop_path: string | null;
  release_date: string;
  vote_average: number;
  runtime: number;
  tagline: string;
  genres: { id: number; name: string }[];
}

export interface Genre {
  id: number;
  name: string;
}
Build the home page Server Component

With the fetch utility ready, you can now build the home page. This is where you'll see the power of Server Components. The component fetches data at render time on the server, then sends fully rendered HTML to the browser.

  • Open app/page.tsx in VS Code (it already exists from the scaffold).
  • Select all the existing content and replace it with the following code:
import Link from "next/link";
import { fetchFromTMDB, Movie } from "./lib/tmdb";

export default async function HomePage() {
  // Fetch popular movies from TMDB on the server
  const data = await fetchFromTMDB("/movie/popular?language=en-US&page=1");
  const movies: Movie[] = data.results;

  return (
    <div>
      <h1 className="text-3xl font-bold mb-8">Popular Movies</h1>
      <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-6">
        {movies.map((movie) => (
          <Link key={movie.id} href={`/movies/${movie.id}`} className="group">
            <div className="relative aspect-[2/3] rounded-lg overflow-hidden">
              {movie.poster_path ? (
                <img
                  src={`https://image.tmdb.org/t/p/w500${movie.poster_path}`}
                  alt={movie.title}
                  className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
                />
              ) : (
                <div className="w-full h-full bg-gray-800 flex items-center justify-center">
                  <span className="text-gray-500">No Image</span>
                </div>
              )}
            </div>
            <h2 className="mt-2 text-sm font-medium truncate group-hover:text-blue-400">
              {movie.title}
            </h2>
            <div className="flex items-center gap-2 text-xs text-gray-400">
              <span>{movie.release_date?.slice(0, 4)}</span>
              <span className="text-yellow-500">
                {movie.vote_average.toFixed(1)}
              </span>
            </div>
          </Link>
        ))}
      </div>
    </div>
  );
}

What does this code do?

  • The async keyword on HomePage makes this a Server Component that can await data before rendering. No useEffect or useState needed.
  • The TMDB image URL pattern https://image.tmdb.org/t/p/w500 requests a 500px-wide poster. TMDB stores images at different sizes and you pick the width you need.
  • Each movie card is wrapped in a Link that points to /movies/${movie.id}. You'll build that page in the next step.
  • The responsive grid uses Tailwind CSS classes to show 2 columns on mobile, scaling up to 5 on large screens.

Why Server Components for data fetching?

In a traditional React app, you'd fetch data on the client with useEffect. The browser sends a request, waits for the response, then renders. The user stares at a loading spinner.

With Server Components, data fetching happens on the server before the HTML is sent. The user sees a fully rendered page immediately. Your API token stays on the server, never exposed to the browser's network tab.

  • Save app/page.tsx.
  • Refresh your browser at http://localhost:3000. You should see a grid of movie posters with titles, years, and ratings below each one.

Not seeing any movies?

  • Check that your .env.local file contains TMDB_API_TOKEN with your API Read Access Token (the long Bearer token, not the shorter API key).
  • Restart your dev server after editing .env.local. Next.js only reads environment variables at startup.
  • Look at your terminal for error messages. If you see TMDB API error: 401, your token is invalid or missing.

Still stuck? Help me debug why my TMDB fetch is failing in my Next.js Server Component.

✔️ Awesome, I've got everything!

Great. Double check you've saved app/page.tsx before moving on.

ⓧ I'd like to double check the full code

import Link from "next/link";
import { fetchFromTMDB, Movie } from "./lib/tmdb";

export default async function HomePage() {
  const data = await fetchFromTMDB("/movie/popular?language=en-US&page=1");
  const movies: Movie[] = data.results;

  return (
    <div>
      <h1 className="text-3xl font-bold mb-8">Popular Movies</h1>
      <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-6">
        {movies.map((movie) => (
          <Link key={movie.id} href={`/movies/${movie.id}`} className="group">
            <div className="relative aspect-[2/3] rounded-lg overflow-hidden">
              {movie.poster_path ? (
                <img
                  src={`https://image.tmdb.org/t/p/w500${movie.poster_path}`}
                  alt={movie.title}
                  className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
                />
              ) : (
                <div className="w-full h-full bg-gray-800 flex items-center justify-center">
                  <span className="text-gray-500">No Image</span>
                </div>
              )}
            </div>
            <h2 className="mt-2 text-sm font-medium truncate group-hover:text-blue-400">
              {movie.title}
            </h2>
            <div className="flex items-center gap-2 text-xs text-gray-400">
              <span>{movie.release_date?.slice(0, 4)}</span>
              <span className="text-yellow-500">
                {movie.vote_average.toFixed(1)}
              </span>
            </div>
          </Link>
        ))}
      </div>
    </div>
  );
}
Add the root layout with navigation

The movie grid works, but the page uses the default scaffold layout. You'll now update the root layout to add a dark theme and a navigation header that persists across all pages.

  • First, create a placeholder for the search bar component that you'll build in a later step.
  • In the Explorer sidebar, right-click the app folder and select New Folder.
  • Name the folder components.
  • Right-click the components folder and select New File.
  • Name the file SearchBar.tsx and add this placeholder code:
export default function SearchBar() {
  // Placeholder - full search will be built in a later step
  return <div />;
}
  • Save app/components/SearchBar.tsx.
  • Now open app/layout.tsx and replace all of its content with the following:
import type { Metadata } from "next";
import Link from "next/link";
import "./globals.css";
import SearchBar from "./components/SearchBar";

export const metadata: Metadata = {
  title: "Movie Browser",
  description: "Browse popular movies with Next.js 16",
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body className="bg-gray-900 text-white min-h-screen">
        <header className="border-b border-gray-800 px-6 py-4">
          <nav className="max-w-7xl mx-auto flex items-center justify-between">
            <Link href="/" className="text-2xl font-bold text-white">
              Movie Browser
            </Link>
            <SearchBar />
          </nav>
        </header>
        <main className="max-w-7xl mx-auto px-6 py-8">{children}</main>
      </body>
    </html>
  );
}

What does this layout do?

  • The RootLayout wraps every page in the app. It sets the dark background (bg-gray-900) and white text globally so every page shares the same theme.
  • The header contains a Link component pointing to / (the home page) and a placeholder for the search bar. The nav stays visible as the user navigates between pages.
  • The {children} prop is where each page's content renders. This is how Next.js layouts share structure across routes without re-rendering the shell on navigation.
  • Save app/layout.tsx.
  • Refresh your browser at http://localhost:3000. You should see a dark background with a "Movie Browser" title in the top-left corner, a subtle border below the header, and your movie grid displayed below.

Seeing a blank page or build error?

  • Make sure you created app/components/SearchBar.tsx with the placeholder code. The layout imports this file, so it must exist.
  • Check that the import path in layout.tsx is "./components/SearchBar" (no file extension needed).
  • If your terminal shows a module-not-found error, confirm the file is inside app/components/ (not in the project root).

Still stuck? Help me fix a module not found error in my Next.js layout file.

✔️ Awesome, I've got everything!

Great. Double check you've saved both app/components/SearchBar.tsx and app/layout.tsx before moving on.

ⓧ I'd like to double check the full code

import type { Metadata } from "next";
import Link from "next/link";
import "./globals.css";
import SearchBar from "./components/SearchBar";

export const metadata: Metadata = {
  title: "Movie Browser",
  description: "Browse popular movies with Next.js 16",
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body className="bg-gray-900 text-white min-h-screen">
        <header className="border-b border-gray-800 px-6 py-4">
          <nav className="max-w-7xl mx-auto flex items-center justify-between">
            <Link href="/" className="text-2xl font-bold text-white">
              Movie Browser
            </Link>
            <SearchBar />
          </nav>
        </header>
        <main className="max-w-7xl mx-auto px-6 py-8">{children}</main>
      </body>
    </html>
  );
}

Your movie grid is live and pulling real data from the TMDB API. Next up, you'll make each movie card clickable by building dynamic detail pages with the App Router.

Add Dynamic Movie Detail Pages

Your home page now shows a grid of popular movies, each one linking to /movies/{id}. Right now, clicking a movie leads nowhere because that route doesn't exist yet.

In this step, you'll create a dynamic route that catches every movie ID in the URL, fetches that movie's details from TMDB, and renders a rich detail page. You'll also add loading and error states using Next.js file conventions.

In this step, get ready to:
  • Create a dynamic route folder for individual movie pages.
  • Build an async Server Component that fetches and displays movie details.
  • Add loading and error states using Next.js file conventions.
Create the dynamic route structure

The App Router uses your folder structure to define routes. A folder named with square brackets like [id] creates a dynamic segment that matches any value in the URL.

  • In VS Code's Explorer sidebar, right-click the app folder and select New Folder.
  • Name the folder movies.
  • Right-click the movies folder and create another folder inside it named [id] (include the square brackets).
  • Right-click the [id] folder and select New File. Name it page.tsx.

What is a dynamic route?

When you visit /movies/550, Next.js matches the [id] folder and passes 550 as the id parameter to your page component.

This means one file handles every movie in the database. You don't need to create a separate page per movie.

Build the detail page UI

Your page component needs to read the movie ID from the URL, fetch that movie's details from TMDB, and render the full detail view.

  • Add the following code to app/movies/[id]/page.tsx:
import Link from "next/link";
import { fetchFromTMDB, MovieDetails } from "../../lib/tmdb";

export default async function MoviePage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  const movie: MovieDetails = await fetchFromTMDB(
    `/movie/${id}?language=en-US`
  );

  return (
    <div>
      <Link
        href="/"
        className="inline-flex items-center text-blue-400 hover:text-blue-300 mb-6"
      >
        &larr; Back to movies
      </Link>

      {movie.backdrop_path && (
        <div className="relative w-full h-64 md:h-96 rounded-xl overflow-hidden mb-8">
          <img
            src={`https://image.tmdb.org/t/p/w1280${movie.backdrop_path}`}
            alt={movie.title}
            className="w-full h-full object-cover"
          />
          <div className="absolute inset-0 bg-gradient-to-t from-gray-900 to-transparent" />
        </div>
      )}

      <div className="max-w-3xl">
        <h1 className="text-4xl font-bold mb-2">{movie.title}</h1>
        {movie.tagline && (
          <p className="text-gray-400 italic mb-4">{movie.tagline}</p>
        )}

        <div className="flex flex-wrap gap-2 mb-4">
          {movie.genres.map((genre) => (
            <span
              key={genre.id}
              className="px-3 py-1 bg-gray-800 rounded-full text-sm"
            >
              {genre.name}
            </span>
          ))}
        </div>

        <div className="flex items-center gap-4 text-sm text-gray-400 mb-6">
          <span className="text-yellow-500 font-medium">
            {movie.vote_average.toFixed(1)} / 10
          </span>
          <span>{movie.runtime} min</span>
          <span>{movie.release_date}</span>
        </div>

        <p className="text-gray-300 leading-relaxed text-lg">
          {movie.overview}
        </p>
      </div>
    </div>
  );
}

What does this code do?

This is a Server Component (the default in the App Router). It runs on the server, so the TMDB fetch happens before any HTML reaches the browser.

  • In Next.js 16, params is a Promise. You must await it before accessing the id value.
  • The fetchFromTMDB utility you built earlier handles the Bearer token auth automatically.
  • The backdrop image uses TMDB's w1280 size for a high-resolution hero banner.
  • A gradient overlay (bg-gradient-to-t from-gray-900) blends the image into the dark page background.
  • Genres render as pill-shaped badges using Tailwind CSS utility classes.
  • Save app/movies/[id]/page.tsx.
  • Switch back to your browser at http://localhost:3000 and click any movie poster.
  • You should see the movie's backdrop image at the top, followed by the title, genre pills, rating, runtime, and a full overview paragraph.

Seeing a blank page or error?

  • Make sure the folder is named exactly [id] with square brackets. Without them, Next.js treats it as a static route.
  • Check that your import path says "../../lib/tmdb" (two levels up from the [id] folder).
  • Confirm your dev server is still running. If it crashed, run npm run dev again in your terminal.

Why isn't my movie detail page loading?

✔️ 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 Link from "next/link";
import { fetchFromTMDB, MovieDetails } from "../../lib/tmdb";

export default async function MoviePage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  const movie: MovieDetails = await fetchFromTMDB(
    `/movie/${id}?language=en-US`
  );

  return (
    <div>
      <Link
        href="/"
        className="inline-flex items-center text-blue-400 hover:text-blue-300 mb-6"
      >
        &larr; Back to movies
      </Link>

      {movie.backdrop_path && (
        <div className="relative w-full h-64 md:h-96 rounded-xl overflow-hidden mb-8">
          <img
            src={`https://image.tmdb.org/t/p/w1280${movie.backdrop_path}`}
            alt={movie.title}
            className="w-full h-full object-cover"
          />
          <div className="absolute inset-0 bg-gradient-to-t from-gray-900 to-transparent" />
        </div>
      )}

      <div className="max-w-3xl">
        <h1 className="text-4xl font-bold mb-2">{movie.title}</h1>
        {movie.tagline && (
          <p className="text-gray-400 italic mb-4">{movie.tagline}</p>
        )}

        <div className="flex flex-wrap gap-2 mb-4">
          {movie.genres.map((genre) => (
            <span
              key={genre.id}
              className="px-3 py-1 bg-gray-800 rounded-full text-sm"
            >
              {genre.name}
            </span>
          ))}
        </div>

        <div className="flex items-center gap-4 text-sm text-gray-400 mb-6">
          <span className="text-yellow-500 font-medium">
            {movie.vote_average.toFixed(1)} / 10
          </span>
          <span>{movie.runtime} min</span>
          <span>{movie.release_date}</span>
        </div>

        <p className="text-gray-300 leading-relaxed text-lg">
          {movie.overview}
        </p>
      </div>
    </div>
  );
}
Add loading and error states

Next.js uses special file names to handle loading and error states automatically. A loading.tsx file renders instantly while the page fetches data. An error.tsx file catches runtime errors and shows a recovery UI.

  • Create a new file called loading.tsx inside app/movies/[id]/ and add this skeleton UI:
export default function Loading() {
  return (
    <div className="animate-pulse">
      <div className="h-6 w-32 bg-gray-800 rounded mb-6" />
      <div className="w-full h-64 md:h-96 bg-gray-800 rounded-xl mb-8" />
      <div className="max-w-3xl">
        <div className="h-10 w-64 bg-gray-800 rounded mb-4" />
        <div className="h-4 w-48 bg-gray-800 rounded mb-4" />
        <div className="flex gap-2 mb-6">
          <div className="h-8 w-20 bg-gray-800 rounded-full" />
          <div className="h-8 w-24 bg-gray-800 rounded-full" />
          <div className="h-8 w-16 bg-gray-800 rounded-full" />
        </div>
        <div className="space-y-3">
          <div className="h-4 bg-gray-800 rounded w-full" />
          <div className="h-4 bg-gray-800 rounded w-full" />
          <div className="h-4 bg-gray-800 rounded w-3/4" />
        </div>
      </div>
    </div>
  );
}

What does this code do?

This component mirrors the shape of your detail page using gray placeholder blocks. The animate-pulse class from Tailwind makes them gently fade in and out, signaling to users that content is loading.

Next.js automatically wraps your page in a React Suspense boundary and shows this component while the async page resolves.

  • Save loading.tsx.
  • Navigate between the home page and a movie detail page. You may see a brief pulsing skeleton flash before the movie content appears.
  • Now create another file called error.tsx inside app/movies/[id]/ and add this code:
"use client";

export default function Error({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  return (
    <div className="flex flex-col items-center justify-center py-20">
      <h2 className="text-2xl font-bold mb-4">Something went wrong</h2>
      <p className="text-gray-400 mb-6">{error.message}</p>
      <button
        onClick={() => reset()}
        className="px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors"
      >
        Try again
      </button>
    </div>
  );
}

Why does this file need 'use client'?

Next.js requires error.tsx to be a Client Component because it uses the browser's error boundary mechanism. The reset function re-renders the page segment, letting users retry without a full page refresh.

The "use client" directive at the top tells Next.js to run this component in the browser instead of on the server.

  • Save error.tsx.
  • Test the error state by visiting http://localhost:3000/movies/99999999 in your browser (a non-existent movie ID).
  • You should see "Something went wrong" with the error message and a Try again button.

Not seeing the error page?

  • Make sure error.tsx starts with "use client"; on its very first line. Without it, Next.js cannot use this file as an error boundary.
  • Confirm the file is named exactly error.tsx (not Error.tsx). Next.js file conventions are case-sensitive.

Why isn't my error.tsx catching the error?

✔️ Awesome, I've got everything!

Great. Double check you've saved both loading.tsx and error.tsx.

ⓧ I'd like to double check the full code

Here is loading.tsx:

export default function Loading() {
  return (
    <div className="animate-pulse">
      <div className="h-6 w-32 bg-gray-800 rounded mb-6" />
      <div className="w-full h-64 md:h-96 bg-gray-800 rounded-xl mb-8" />
      <div className="max-w-3xl">
        <div className="h-10 w-64 bg-gray-800 rounded mb-4" />
        <div className="h-4 w-48 bg-gray-800 rounded mb-4" />
        <div className="flex gap-2 mb-6">
          <div className="h-8 w-20 bg-gray-800 rounded-full" />
          <div className="h-8 w-24 bg-gray-800 rounded-full" />
          <div className="h-8 w-16 bg-gray-800 rounded-full" />
        </div>
        <div className="space-y-3">
          <div className="h-4 bg-gray-800 rounded w-full" />
          <div className="h-4 bg-gray-800 rounded w-full" />
          <div className="h-4 bg-gray-800 rounded w-3/4" />
        </div>
      </div>
    </div>
  );
}

Here is error.tsx:

"use client";

export default function Error({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  return (
    <div className="flex flex-col items-center justify-center py-20">
      <h2 className="text-2xl font-bold mb-4">Something went wrong</h2>
      <p className="text-gray-400 mb-6">{error.message}</p>
      <button
        onClick={() => reset()}
        className="px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors"
      >
        Try again
      </button>
    </div>
  );
}

Your movie detail pages are fully wired up with data fetching, a polished UI, and graceful fallbacks for loading and errors. Next up, you'll add a search bar so users can find movies by title without scrolling through the grid.

Add Search with a Client Component

Your movie grid and detail pages both fetch data on the server. The user never has to wait for a loading spinner on the first paint. But search is different.

Search requires responding to keystrokes in real time, managing input state, and updating the UI without a full page reload. This is exactly what Client Components are designed for. In this step, you will build a search bar that debounces user input, fetches results through a server-side proxy, and displays them in a dropdown overlay.

In this step, get ready to:
  • Create a Client Component search bar with debounced input and a dropdown overlay.
  • Build an API route handler that proxies search requests to TMDB.
  • Verify the search integration in the app layout.
Create the API route handler

Before building the search UI, you need a server-side endpoint for it to call. If the search bar fetched TMDB directly from the browser, your API token would be exposed in network requests.

Why proxy through a Route Handler?

A Route Handler runs on the server, just like a Server Component. By routing search requests through /api/search, the TMDB token stays in process.env and never reaches the browser.

This pattern also lets you add rate limiting, caching, or response shaping later without touching the client code.

  • In VS Code, create the folder app/api/search/ inside your movie-browser project.
  • Create a new file called route.ts inside app/api/search/.
  • Add the following code to app/api/search/route.ts:
import { NextRequest, NextResponse } from "next/server";

// Handle GET requests to /api/search
export async function GET(request: NextRequest) {
  // Read the "query" parameter from the URL
  const query = request.nextUrl.searchParams.get("query");

  // Return empty results if no query provided
  if (!query) {
    return NextResponse.json({ results: [] });
  }

  // Proxy the request to TMDB with the server-side token
  const response = await fetch(
    `https://api.themoviedb.org/3/search/movie?query=${encodeURIComponent(query)}&language=en-US&page=1`,
    {
      headers: {
        Authorization: `Bearer ${process.env.TMDB_API_TOKEN}`,
        "Content-Type": "application/json",
      },
    }
  );

  if (!response.ok) {
    return NextResponse.json(
      { error: "Failed to search movies" },
      { status: response.status }
    );
  }

  const data = await response.json();
  return NextResponse.json(data);
}

What does this code do?

  • NextRequest gives you easy access to the URL's search params via request.nextUrl.searchParams.
  • If no query param exists, it short-circuits with an empty results array.
  • The fetch call hits TMDB's search endpoint with the Bearer token from your environment variable.
  • NextResponse.json() wraps the response as JSON and forwards it to the client.
  • Save app/api/search/route.ts.
  • Open your browser and visit http://localhost:3000/api/search?query=batman. You should see a JSON response with movie results containing titles like "Batman" and "The Batman".

Seeing an empty response or error?

  • Check that your .env.local file contains TMDB_API_TOKEN with a valid token. Restart your dev server after editing .env.local (environment variables require a restart).
  • Make sure the URL is exactly /api/search?query=batman with no typos in the path.

Still stuck? Why is my /api/search route returning an error or empty results?

Create the search bar Client Component

Now that the API route is ready, you can build the component that calls it. This is your first Client Component in the project. Adding "use client" at the top of a file tells Next.js to include it in the browser JavaScript bundle so it can use React hooks like useState and useEffect.

  • Create a folder called components inside your app/ directory.
  • Create a new file called SearchBar.tsx inside app/components/.
  • Add the following code to app/components/SearchBar.tsx:
"use client";

import { useState, useEffect, useRef } from "react";
import Link from "next/link";

// Shape of each search result from the API
interface SearchResult {
  id: number;
  title: string;
  poster_path: string | null;
  release_date: string;
}

export default function SearchBar() {
  // Track the text input, search results, and dropdown visibility
  const [query, setQuery] = useState("");
  const [results, setResults] = useState<SearchResult[]>([]);
  const [isOpen, setIsOpen] = useState(false);
  const containerRef = useRef<HTMLDivElement>(null);

  // Debounced search: waits 300ms after typing stops before fetching
  useEffect(() => {
    if (!query.trim()) {
      setResults([]);
      setIsOpen(false);
      return;
    }

    const timer = setTimeout(async () => {
      const response = await fetch(
        `/api/search?query=${encodeURIComponent(query)}`
      );
      const data = await response.json();
      setResults(data.results?.slice(0, 5) || []);
      setIsOpen(true);
    }, 300);

    return () => clearTimeout(timer);
  }, [query]);

  // Close dropdown when clicking outside the search container
  useEffect(() => {
    function handleClickOutside(event: MouseEvent) {
      if (
        containerRef.current &&
        !containerRef.current.contains(event.target as Node)
      ) {
        setIsOpen(false);
      }
    }
    document.addEventListener("mousedown", handleClickOutside);
    return () => document.removeEventListener("mousedown", handleClickOutside);
  }, []);

  return (
    <div ref={containerRef} className="relative">
      <input
        type="text"
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="Search movies..."
        className="w-64 px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm text-white placeholder-gray-500 focus:outline-none focus:border-blue-500"
      />
      {isOpen && results.length > 0 && (
        <div className="absolute top-full mt-2 w-80 bg-gray-800 border border-gray-700 rounded-lg shadow-xl z-50">
          {results.map((movie) => (
            <Link
              key={movie.id}
              href={`/movies/${movie.id}`}
              onClick={() => {
                setIsOpen(false);
                setQuery("");
              }}
              className="flex items-center gap-3 p-3 hover:bg-gray-700 first:rounded-t-lg last:rounded-b-lg"
            >
              {movie.poster_path ? (
                <img
                  src={`https://image.tmdb.org/t/p/w92${movie.poster_path}`}
                  alt={movie.title}
                  className="w-10 h-14 object-cover rounded"
                />
              ) : (
                <div className="w-10 h-14 bg-gray-700 rounded" />
              )}
              <div>
                <p className="text-sm font-medium">{movie.title}</p>
                <p className="text-xs text-gray-400">
                  {movie.release_date?.slice(0, 4)}
                </p>
              </div>
            </Link>
          ))}
        </div>
      )}
    </div>
  );
}

What does this code do?

  • "use client" marks this as a Client Component so React hooks work in the browser.
  • The first useEffect watches the query state. When the user stops typing for 300ms, it fetches from your /api/search route and limits results to 5 items.
  • The second useEffect adds a click-outside listener using a ref so the dropdown closes when the user clicks elsewhere on the page.
  • Each result is a Link to /movies/[id], so clicking navigates to the movie detail page using the dynamic route you built earlier.
  • Save app/components/SearchBar.tsx.
  • Return to your browser at http://localhost:3000. The page should now load without errors, with a search input visible in the top-right of the nav bar.

Page still showing an error?

  • Make sure the file is at exactly app/components/SearchBar.tsx. The layout imports from ./components/SearchBar, so the folder name must be components (lowercase).
  • Confirm "use client" is the very first line in the file, before any imports.
  • Check your terminal for a syntax error. A missing closing brace or parenthesis will prevent the component from compiling.

Still stuck? Why is my SearchBar component not loading in the layout?

Test the search integration

Your layout already imports and renders the SearchBar component. Now that both files exist, the full search flow is connected.

  • In your browser, click the search input in the nav bar.
  • Type inception and wait about half a second.
  • A dropdown should appear below the input showing up to 5 movie results with poster thumbnails and release years.
  • Click one of the results. You should navigate to that movie's detail page.
  • Click anywhere outside the dropdown to confirm it closes.

Why does the layout already import SearchBar?

In the previous step, the layout was set up with the SearchBar import already in place. This is a common pattern in Next.js projects. You build the layout structure first, then fill in the component implementations.

Until this step, the dev server was showing an error because the file did not exist yet. Now that you have created it, everything connects automatically.

Server Components vs Client Components

Your home page and movie detail pages are Server Components. They run on the server, fetch data, and send finished HTML to the browser. The browser never downloads the TMDB fetch logic.

SearchBar is a Client Component. It ships JavaScript to the browser because it needs to respond to user input in real time. The rule is simple: if it needs hooks or browser events, make it a Client Component. Otherwise, leave it as a Server Component.

✔️ Awesome, I've got everything!

Great. Double check that both new files are saved and your search works end-to-end.

ⓧ I'd like to double check the full code

Here is the full code for both new files:

import { NextRequest, NextResponse } from "next/server";

export async function GET(request: NextRequest) {
  const query = request.nextUrl.searchParams.get("query");

  if (!query) {
    return NextResponse.json({ results: [] });
  }

  const response = await fetch(
    `https://api.themoviedb.org/3/search/movie?query=${encodeURIComponent(query)}&language=en-US&page=1`,
    {
      headers: {
        Authorization: `Bearer ${process.env.TMDB_API_TOKEN}`,
        "Content-Type": "application/json",
      },
    }
  );

  if (!response.ok) {
    return NextResponse.json(
      { error: "Failed to search movies" },
      { status: response.status }
    );
  }

  const data = await response.json();
  return NextResponse.json(data);
}
"use client";

import { useState, useEffect, useRef } from "react";
import Link from "next/link";

interface SearchResult {
  id: number;
  title: string;
  poster_path: string | null;
  release_date: string;
}

export default function SearchBar() {
  const [query, setQuery] = useState("");
  const [results, setResults] = useState<SearchResult[]>([]);
  const [isOpen, setIsOpen] = useState(false);
  const containerRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    if (!query.trim()) {
      setResults([]);
      setIsOpen(false);
      return;
    }

    const timer = setTimeout(async () => {
      const response = await fetch(
        `/api/search?query=${encodeURIComponent(query)}`
      );
      const data = await response.json();
      setResults(data.results?.slice(0, 5) || []);
      setIsOpen(true);
    }, 300);

    return () => clearTimeout(timer);
  }, [query]);

  useEffect(() => {
    function handleClickOutside(event: MouseEvent) {
      if (
        containerRef.current &&
        !containerRef.current.contains(event.target as Node)
      ) {
        setIsOpen(false);
      }
    }
    document.addEventListener("mousedown", handleClickOutside);
    return () => document.removeEventListener("mousedown", handleClickOutside);
  }, []);

  return (
    <div ref={containerRef} className="relative">
      <input
        type="text"
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="Search movies..."
        className="w-64 px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm text-white placeholder-gray-500 focus:outline-none focus:border-blue-500"
      />
      {isOpen && results.length > 0 && (
        <div className="absolute top-full mt-2 w-80 bg-gray-800 border border-gray-700 rounded-lg shadow-xl z-50">
          {results.map((movie) => (
            <Link
              key={movie.id}
              href={`/movies/${movie.id}`}
              onClick={() => {
                setIsOpen(false);
                setQuery("");
              }}
              className="flex items-center gap-3 p-3 hover:bg-gray-700 first:rounded-t-lg last:rounded-b-lg"
            >
              {movie.poster_path ? (
                <img
                  src={`https://image.tmdb.org/t/p/w92${movie.poster_path}`}
                  alt={movie.title}
                  className="w-10 h-14 object-cover rounded"
                />
              ) : (
                <div className="w-10 h-14 bg-gray-700 rounded" />
              )}
              <div>
                <p className="text-sm font-medium">{movie.title}</p>
                <p className="text-xs text-gray-400">
                  {movie.release_date?.slice(0, 4)}
                </p>
              </div>
            </Link>
          ))}
        </div>
      )}
    </div>
  );
}

Your movie browser now has real-time search powered by a Client Component on the frontend and a Route Handler on the backend. You have seen both sides of the Next.js component model in action. Up next, a Secret Mission that takes your app further with genre filtering and parallel data fetching.

Secret mission

Add Genre Filtering with Parallel Fetches

Your movie browser shows popular movies, but what if a user only wants to see Action or Comedy films? In this secret mission, you'll add genre filtering to the home page using parallel data fetching and URL-based state management.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

This project runs entirely locally with a free TMDB API key, so there are no ongoing costs.

Resources you used:

  • Local movie-browser project folder on your Desktop.
  • TMDB API key (free tier, no charges).
  • Next.js dev server running at http://localhost:3000.

Keep everything running

No action needed. Choose this if you're still actively building or experimenting with your movie browser.

  • Your dev server stays running at http://localhost:3000.
  • Your project files remain in ~/Desktop/movie-browser.
  • Your TMDB API key stays active (free, no charges).

Pause - I'll come back to this later

Stop the dev server to free up your terminal. Your project files and API key stay intact for next time.

  • Switch to the terminal where your dev server is running.
  • Press Ctrl+C to stop the server.
  • When you come back, run npm run dev from the movie-browser folder to start it again.

Delete - I don't want to use this again

Remove all project files from your machine. This is permanent.

  • Switch to the terminal where your dev server is running and press Ctrl+C to stop it.
  • Delete the project folder by running this command (macOS/Linux):
rm -rf ~/Desktop/movie-browser

On Windows, run this instead:

rmdir /s /q "$env:USERPROFILE\Desktop\movie-browser"
  • Confirm the folder is gone by running:
ls ~/Desktop

You should no longer see movie-browser in the output.

What about my TMDB account?

Your TMDB account and API key are completely free with no usage charges. You can leave them as-is with no financial impact.

That's a Wrap!

That's a Wrap!

Nice work! You just built a fully functional movie browsing application powered by real data from the TMDB API, using Next.js 16's most important patterns.

You've learned how to:

  • Fetch real data inside Server Components (no useEffect, no client-side loading spinners) and render it with the App Router's file-based routing system.
  • Create dynamic routes with [id] folders for individual movie pages, and use Next.js file conventions like loading.tsx and error.tsx for built-in UX patterns.
  • Draw a clear line between Server Components and Client Components by building an interactive search bar with a Route Handler that keeps API secrets server-side.
  • Secret Mission: Implement genre filtering with parallel data fetching using Promise.all and URL-based state management via search params.

Ready to quiz yourself?