Build a Movie Collection with Next.js
Learn Next.js App Router with file-system routing, layouts, and dynamic routes.
Introduction
30 Second Summary
Think about how you navigate a website. You click links, land on different pages, and some parts of the page (like the navigation bar) stay put while the content changes.
In this project, you will build a multi-page movie collection app using Next.js and the App Router to learn how modern web frameworks organize pages, share layouts, and decide what code runs on the server versus the browser.
What You'll Build
A Next.js 16 application with file-system routing, shared layouts, dynamic routes for individual movie pages, and an interactive favorites button that demonstrates the server/client boundary.
By the end of this project, you'll have:
- A Next.js project with five route segments (/, /about, /movies, /movies/[id]) and a persistent navigation bar.
- A nested layout that wraps only the movies section, plus a root layout that stays mounted across all page navigations.
- Dynamic route pages that render individual movie details from URL parameters, with a Client Component favorites button demonstrating when to use "use client".
- Secret Mission: Add custom "Not Found" pages at both the global and route-segment levels.
Are there any prerequisites?
This project assumes you are familiar with React (components, props, state, hooks) and basic TypeScript. You do not need any prior experience with Next.js.
Set Up Your Next.js Project
Every multi-page app starts with a solid foundation. In this project, you are building a movie collection app that uses file-system routing, layouts, and dynamic pages.
Before you can build any of that, you need a Next.js project scaffolded with the right tools. In this step, you will create the project, clean up the default boilerplate, and set up a data file that the rest of the app will pull from.
In this step, get ready to:
- Scaffold a new Next.js project with TypeScript and Tailwind CSS.
- Clean the default boilerplate and replace it with a simple home page.
- Create a movie data file that serves as the data source for the entire app.
Scaffold a new Next.js project
Next.js provides a CLI tool called create-next-app that generates a complete project structure in one command. You will use the --yes flag to accept all recommended defaults automatically.
- Open your terminal by pressing Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal (macOS) or Command Prompt (Windows) and press Enter to open it.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your Next.js project by running this command:
npx create-next-app@latest movie-collection --yes
What does this command do?
npx create-next-app@latest downloads and runs the latest version of the Next.js project scaffolding tool.
movie-collection is the name of the folder that gets created. The --yes flag tells it to accept all recommended defaults: TypeScript, Tailwind CSS, ESLint, App Router, Turbopack, and the @/* import alias.
Seeing a prompt to install a package?
If this is your first time running npx with create-next-app, you may see a message saying Need to install the following packages. Type y and press Enter to continue.
- Confirm the project folder was created by running:
ls
You should see movie-collection listed in the output.
- Move into your project folder by running:
cd movie-collection
Now open VS Code to this project so you can edit files and run the dev server from one place.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Visual Studio Code and press Enter to open it.
- Click File in the top menu bar.
- Click Open Folder.
- Navigate to your Desktop, select the movie-collection folder, and click Open.
You should see the project files appear in the Explorer sidebar on the left.
- Open the integrated terminal in VS Code by pressing Ctrl+` (both macOS and Windows).
- Start the development server by running:
npm run dev
- Open your browser and navigate to http://localhost:3000.
You should see the default Next.js welcome page with the Vercel logo and some getting-started links.
Don't see the welcome page?
- Make sure the terminal shows Local: http://localhost:3000 in the output. If not, check for error messages above.
- If you see a message about port 3000 being in use, close any other applications using that port and run npm run dev again.
- Make sure you are inside the movie-collection folder when running the command.
Help me troubleshoot my Next.js dev server not starting.
Clean the default boilerplate
The scaffold comes with demo content you do not need. You will replace it with a clean starting point for your movie collection app.
- In the VS Code Explorer sidebar, expand the app folder and click on globals.css to open it.
- Select all the content in the file by pressing Cmd+A (macOS) or Ctrl+A (Windows).
- Delete everything and replace it with the following code:
@tailwind base;
@tailwind components;
@tailwind utilities;
What does this code do?
These three directives import Tailwind CSS's base reset styles, reusable component classes, and utility classes. This is all your stylesheet needs because you will style everything using Tailwind utility classes directly in your components.
- Save globals.css by pressing Cmd+S (macOS) or Ctrl+S (Windows).
Now replace the home page content.
- In the app folder, click on page.tsx to open it.
- Select all the content and replace it with the following code:
import Link from "next/link"
export default function Home() {
return (
<div className="text-center py-20">
<h1 className="text-4xl font-bold text-gray-900 mb-4">
Welcome to Movie Collection
</h1>
<p className="text-lg text-gray-600 mb-8">
Explore our curated collection of classic and modern films.
</p>
<Link
href="/movies"
className="inline-block bg-blue-600 text-white px-6 py-3 rounded-lg hover:bg-blue-700 transition-colors"
>
Browse Movies
</Link>
</div>
)
}
What does this code do?
This is a Server Component (the default in Next.js) that renders a centered welcome message.
- Link from next/link is Next.js's built-in navigation component. It enables client-side transitions between pages without a full browser reload.
- The className attributes use Tailwind CSS utility classes to style the heading, paragraph, and button.
- The href="/movies" link points to a route you will create in the next step.
- Save page.tsx.
- Switch to your browser and check http://localhost:3000.
You should see a clean white page with "Welcome to Movie Collection" in large bold text and a blue "Browse Movies" button below it.
Still seeing the old default page?
- Make sure you saved both globals.css and page.tsx. Unsaved files show a dot next to their name in the VS Code tab bar.
- Hard-refresh your browser by pressing Cmd+Shift+R (macOS) or Ctrl+Shift+R (Windows).
- Check that your dev server is still running in the terminal. If it stopped, run npm run dev again.
My browser isn't showing the updated home page after saving.
✔️ Awesome, I've got everything!
Great. Make sure both files are saved before moving on.
ⓧ I'd like to double check the full code
Here is the full content of each file at this point.
@tailwind base;
@tailwind components;
@tailwind utilities;
import Link from "next/link"
export default function Home() {
return (
<div className="text-center py-20">
<h1 className="text-4xl font-bold text-gray-900 mb-4">
Welcome to Movie Collection
</h1>
<p className="text-lg text-gray-600 mb-8">
Explore our curated collection of classic and modern films.
</p>
<Link
href="/movies"
className="inline-block bg-blue-600 text-white px-6 py-3 rounded-lg hover:bg-blue-700 transition-colors"
>
Browse Movies
</Link>
</div>
)
}
Create the movie data file
Your app needs movie data to display. Instead of connecting to an external API, you will use a local TypeScript file as the data source. This keeps the focus on routing and component architecture.
- In the VS Code Explorer sidebar, right-click the app folder and select New Folder.
- Name the folder lib and press Enter.
- Right-click the new lib folder and select New File.
- Name the file movies.ts and press Enter.
The file opens in the editor. Start by defining the shape of a movie object.
- Add the following interface to movies.ts:
export interface Movie {
id: string
title: string
year: number
genre: string
rating: number
description: string
}
What does this code do?
The Movie interface tells TypeScript exactly what properties a movie object must have. Every movie in your collection will follow this shape: a string ID, a title, a numeric year, a genre, a numeric rating, and a description.
Exporting the interface lets other files import it to get type safety when working with movie data.
Now add the actual movie data and a helper function below the interface.
- Below the interface, add the movies array and lookup function:
export const movies: Movie[] = [
{
id: "1",
title: "The Shawshank Redemption",
year: 1994,
genre: "Drama",
rating: 9.3,
description:
"A banker convicted of uxoricide forms a friendship over a quarter century with a hardened convict, while maintaining his innocence and trying to remain hopeful through simple compassion.",
},
{
id: "2",
title: "Inception",
year: 2010,
genre: "Sci-Fi",
rating: 8.8,
description:
"A thief who steals corporate secrets through the use of dream-sharing technology is given the inverse task of planting an idea into the mind of a C.E.O.",
},
{
id: "3",
title: "The Dark Knight",
year: 2008,
genre: "Action",
rating: 9.0,
description:
"When a menace known as the Joker wreaks havoc and chaos on the people of Gotham, Batman must accept one of the greatest psychological and physical tests of his ability to fight injustice.",
},
{
id: "4",
title: "Pulp Fiction",
year: 1994,
genre: "Crime",
rating: 8.9,
description:
"The lives of two mob hitmen, a boxer, a gangster and his wife, and a pair of diner bandits intertwine in four tales of violence and redemption.",
},
{
id: "5",
title: "Interstellar",
year: 2014,
genre: "Sci-Fi",
rating: 8.7,
description:
"When Earth becomes uninhabitable in the future, a farmer and ex-NASA pilot is tasked with piloting a spacecraft along with a team of researchers to find a new planet for humans.",
},
{
id: "6",
title: "The Matrix",
year: 1999,
genre: "Sci-Fi",
rating: 8.7,
description:
"A computer hacker learns from mysterious rebels about the true nature of his reality and his role in the war against its controllers.",
},
]
export function getMovieById(id: string): Movie | undefined {
return movies.find((movie) => movie.id === id)
}
What does this code do?
- movies is an exported array of six movie objects that follow the Movie interface. This is your app's entire data source.
- getMovieById is a helper function that takes a string ID and returns the matching movie (or undefined if no match exists). You will use this later when building individual movie detail pages.
- Save movies.ts.
- Check that your file is at the path app/lib/movies.ts in the Explorer sidebar.
You will not see a visual change in the browser because this file only stores data. The next step will import this data and display it on screen.
Seeing a red underline or TypeScript error?
- Make sure the file is named movies.ts (not .tsx). This file has no JSX so it only needs the .ts extension.
- Check that the folder path is app/lib/ and not app/libs/ or at the project root.
- Confirm the interface definition is above the movies array. The array references the Movie type so it must be declared first.
Help me fix a TypeScript error in my movies.ts data file.
✔️ Awesome, I've got everything!
Great. Make sure movies.ts is saved before moving on.
ⓧ I'd like to double check the full code
Here is the full app/lib/movies.ts file:
export interface Movie {
id: string
title: string
year: number
genre: string
rating: number
description: string
}
export const movies: Movie[] = [
{
id: "1",
title: "The Shawshank Redemption",
year: 1994,
genre: "Drama",
rating: 9.3,
description:
"A banker convicted of uxoricide forms a friendship over a quarter century with a hardened convict, while maintaining his innocence and trying to remain hopeful through simple compassion.",
},
{
id: "2",
title: "Inception",
year: 2010,
genre: "Sci-Fi",
rating: 8.8,
description:
"A thief who steals corporate secrets through the use of dream-sharing technology is given the inverse task of planting an idea into the mind of a C.E.O.",
},
{
id: "3",
title: "The Dark Knight",
year: 2008,
genre: "Action",
rating: 9.0,
description:
"When a menace known as the Joker wreaks havoc and chaos on the people of Gotham, Batman must accept one of the greatest psychological and physical tests of his ability to fight injustice.",
},
{
id: "4",
title: "Pulp Fiction",
year: 1994,
genre: "Crime",
rating: 8.9,
description:
"The lives of two mob hitmen, a boxer, a gangster and his wife, and a pair of diner bandits intertwine in four tales of violence and redemption.",
},
{
id: "5",
title: "Interstellar",
year: 2014,
genre: "Sci-Fi",
rating: 8.7,
description:
"When Earth becomes uninhabitable in the future, a farmer and ex-NASA pilot is tasked with piloting a spacecraft along with a team of researchers to find a new planet for humans.",
},
{
id: "6",
title: "The Matrix",
year: 1999,
genre: "Sci-Fi",
rating: 8.7,
description:
"A computer hacker learns from mysterious rebels about the true nature of his reality and his role in the war against its controllers.",
},
]
export function getMovieById(id: string): Movie | undefined {
return movies.find((movie) => movie.id === id)
}
Your project foundation is set. You have a running Next.js app with a clean home page and a data file ready to power the rest of your movie collection. Next up, you will build the root layout with a persistent navigation bar that stays on screen across all pages.
Build the Root Layout and Navigation
Your project is scaffolded, your movie data is ready, and the dev server is running. Now it's time to give your app real structure with navigation that persists across every page.
Every page in your movie collection needs a navigation bar. Instead of duplicating that nav in each page file, you'll define it once in a layout. The App Router keeps layouts mounted across page transitions, so your nav never re-renders when users move between pages.
In this step, get ready to:
- Update the root layout with a persistent navigation bar.
- Create an About page to test multi-page navigation.
- Observe how Server Components render without shipping extra JavaScript.
Update the root layout with navigation links
The root layout is the top-level wrapper for your entire app. Next.js requires it to contain the <html> and <body> tags, and anything you place inside it appears on every page.
- In VS Code, open app/layout.tsx from the Explorer sidebar.
- Select all the existing content and replace it with the following code:
import type { Metadata } from "next"
import { Geist } from "next/font/google"
import Link from "next/link"
import "./globals.css"
// Load the Geist font for the whole app
const geist = Geist({ subsets: ["latin"] })
// Set the page title and description for browser tabs
export const metadata: Metadata = {
title: "Movie Collection",
description: "A Next.js App Router movie collection",
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body className={`${geist.className} antialiased bg-gray-50`}>
{/* Navigation bar that appears on every page */}
<nav className="bg-white shadow-sm border-b border-gray-200">
<div className="max-w-5xl mx-auto px-4 py-4 flex items-center gap-6">
<Link href="/" className="text-lg font-bold text-gray-900">
MovieApp
</Link>
<Link
href="/movies"
className="text-gray-600 hover:text-gray-900 transition-colors"
>
Movies
</Link>
<Link
href="/about"
className="text-gray-600 hover:text-gray-900 transition-colors"
>
About
</Link>
</div>
</nav>
{/* Page content renders here */}
<main className="max-w-5xl mx-auto px-4 py-8">{children}</main>
</body>
</html>
)
}
What does this code do?
The Geist import loads the Geist font (the default font in Next.js 16 projects) so text renders consistently across browsers.
The Link component from next/link handles navigation between pages. It prefetches linked pages in the background and performs client-side transitions without a full page reload.
The {children} prop is where each page's content gets injected. The nav stays mounted above it, so it never re-renders when you navigate.
Why Link instead of anchor tags?
Regular <a> tags cause a full page reload on every click. The Link component intercepts the click, updates the URL, and swaps only the page content. This keeps your layout mounted and transitions fast.
- Save app/layout.tsx.
- Switch back to your browser at http://localhost:3000. You should see a white navigation bar at the top with "MovieApp", "Movies", and "About" links.
Don't see the navigation bar?
- Make sure you saved app/layout.tsx. Unsaved changes won't trigger a refresh.
- Check your terminal for errors. A missing closing tag or mismatched brace will prevent the page from rendering.
- Confirm the file is named exactly layout.tsx (not Layout.tsx). Next.js file conventions are case-sensitive.
Help me debug my root layout
✔️ Awesome, I've got everything!
Great. Double check you've saved your file and the nav appears in the browser.
ⓧ I'd like to double check the full code
import type { Metadata } from "next"
import { Geist } from "next/font/google"
import Link from "next/link"
import "./globals.css"
const geist = Geist({ subsets: ["latin"] })
export const metadata: Metadata = {
title: "Movie Collection",
description: "A Next.js App Router movie collection",
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body className={`${geist.className} antialiased bg-gray-50`}>
<nav className="bg-white shadow-sm border-b border-gray-200">
<div className="max-w-5xl mx-auto px-4 py-4 flex items-center gap-6">
<Link href="/" className="text-lg font-bold text-gray-900">
MovieApp
</Link>
<Link
href="/movies"
className="text-gray-600 hover:text-gray-900 transition-colors"
>
Movies
</Link>
<Link
href="/about"
className="text-gray-600 hover:text-gray-900 transition-colors"
>
About
</Link>
</div>
</nav>
<main className="max-w-5xl mx-auto px-4 py-8">{children}</main>
</body>
</html>
)
}
Create the About page
Your nav has an "About" link pointing to /about, but that route does not exist yet. In the App Router, creating a route is as simple as adding a folder with a page.tsx file inside it.
- In the VS Code Explorer sidebar, right-click the app folder and select New Folder.
- Name the folder about.
- Right-click the new about folder and select New File.
- Name the file page.tsx.
- Paste the following code into the new file:
export default function AboutPage() {
return (
<div>
<h1 className="text-3xl font-bold text-gray-900 mb-4">About</h1>
<p className="text-gray-600 leading-relaxed max-w-2xl">
Movie Collection is a demo application built with Next.js 16 and the App
Router. It demonstrates file-system routing, nested layouts, dynamic
routes, and the Server/Client Component model.
</p>
</div>
)
}
What does this code do?
This is a Server Component. It has no "use client" directive at the top, so Next.js renders it entirely on the server. No JavaScript for this component gets shipped to the browser.
The folder name about becomes the URL path /about. The file named page.tsx tells Next.js this folder is a publicly accessible route.
- Save app/about/page.tsx.
- In your browser, click the About link in the navigation bar. You should see the "About" heading and description text appear below the nav.
- Click MovieApp to go back to the home page. Notice the navigation bar stays in place during both transitions.
Seeing a 404 when you click About?
- Verify the folder structure is app/about/page.tsx. The file must be named exactly page.tsx inside a folder named about.
- Make sure you created the about folder directly inside app, not inside app/lib or another subfolder.
Help me fix my About page route
✔️ Awesome, I've got everything!
Great. Make sure both pages render correctly when you click between them.
ⓧ I'd like to double check the full code
export default function AboutPage() {
return (
<div>
<h1 className="text-3xl font-bold text-gray-900 mb-4">About</h1>
<p className="text-gray-600 leading-relaxed max-w-2xl">
Movie Collection is a demo application built with Next.js 16 and the App
Router. It demonstrates file-system routing, nested layouts, dynamic
routes, and the Server/Client Component model.
</p>
</div>
)
}
Observe the Server Component default
Both your Home page and About page are Server Components. This means Next.js sends pre-rendered HTML to the browser with zero component JavaScript. Let's verify this.
- In your browser, open DevTools by pressing Cmd+Option+I (macOS) or Ctrl+Shift+I (Windows).
- Click the Network tab at the top of the DevTools panel.
- Click the About link in your app's navigation bar.
- Watch the Network tab. You should see a small fetch request appear, not a full page reload.
- Click MovieApp to navigate back to the home page. Again, no full page reload occurs.
What is happening here?
The root layout stays mounted in the browser the entire time. When you navigate, Next.js only swaps the {children} portion of the layout. The nav bar never unmounts or re-renders.
Because both pages are Server Components, Next.js does not ship any page-specific JavaScript bundle. The only JS loaded is the framework runtime that handles client-side navigation.
Your app now has a persistent layout with real navigation between two pages. Next up, you'll build the movies listing page with its own nested layout that only appears within the movies section.
Create the Movies Listing with a Nested Layout
You have a working multi-page app with a shared navigation bar. Clicking between Home and About proves that the root layout persists across navigations.
Now you need a dedicated movies section with its own header that only appears when browsing movies. Next.js solves this with nested layouts. A nested layout wraps a specific route segment and its children without touching the rest of the app.
In this step, get ready to:
- Create a nested layout for the movies section.
- Build a movies listing page with a responsive grid of movie cards.
- Verify that the nested layout appears and disappears based on the current route.
Create the movies nested layout
In the App Router, any folder that contains a layout.tsx file becomes a layout boundary. The layout wraps every page inside that folder and its subfolders.
- In the VS Code Explorer sidebar, right-click the app folder and select New Folder.
- Name the folder movies.
- Right-click the new movies folder and select New File.
- Name the file layout.tsx and paste the following code:
export default function MoviesLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<div>
<header className="mb-6">
<h1 className="text-3xl font-bold text-gray-900">Browse Movies</h1>
<p className="text-gray-500 mt-1">
Select a movie to see its details.
</p>
</header>
{children}
</div>
)
}
What does this code do?
This layout receives children as a prop, which Next.js automatically fills with whatever page component matches the current URL inside /movies.
The <header> with the "Browse Movies" heading renders above every page in this section. The root layout's nav still wraps everything above this nested layout.
Why use a nested layout here?
The movies section has UI that should appear on the listing page AND on individual movie detail pages. A nested layout lets you define that shared UI once, in one file, instead of repeating it on every page.
- Save app/movies/layout.tsx.
You won't see a change yet because there is no page inside the movies folder to render. The next chunk creates the listing page.
✔️ Awesome, I've got everything!
Great. Make sure you have saved the file before moving on.
ⓧ I'd like to double check the full code
export default function MoviesLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<div>
<header className="mb-6">
<h1 className="text-3xl font-bold text-gray-900">Browse Movies</h1>
<p className="text-gray-500 mt-1">
Select a movie to see its details.
</p>
</header>
{children}
</div>
)
}
Build the movies listing page
With the layout in place, you need a page component to display the actual movie data. This page imports the movies array you created in Step 1 and renders each movie as a clickable card.
- Right-click the app/movies folder in the Explorer sidebar and select New File.
- Name it page.tsx and paste the following code:
import Link from "next/link"
import { movies } from "@/app/lib/movies"
export default function MoviesPage() {
return (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{movies.map((movie) => (
<Link
key={movie.id}
href={`/movies/${movie.id}`}
className="block p-4 bg-white rounded-lg border border-gray-200 hover:shadow-md transition-shadow"
>
<h2 className="text-lg font-semibold text-gray-900">{movie.title}</h2>
<p className="text-sm text-gray-500 mt-1">
{movie.year} · {movie.genre}
</p>
<p className="text-sm text-yellow-600 mt-1">
Rating: {movie.rating}/10
</p>
</Link>
))}
</div>
)
}
What does this code do?
- import { movies } from "@/app/lib/movies" pulls in the hardcoded movie array you created earlier. The @/ alias points to the project root.
- movies.map() loops through the array and renders a Link card for each movie.
- href={`/movies/${movie.id}`} uses a template literal to build a unique URL for each movie, like /movies/1 or /movies/2.
- The responsive grid classes (grid-cols-1 sm:grid-cols-2 lg:grid-cols-3) show 1 column on mobile, 2 on tablets, and 3 on larger screens.
- Save app/movies/page.tsx.
- Switch to your browser and navigate to http://localhost:3000/movies.
- You should see the "Browse Movies" section header from your nested layout, followed by a grid of 6 movie cards showing each movie's title, year, genre, and rating.
Don't see the movies grid?
- Confirm the file is saved as app/movies/page.tsx (not app/movie/page.tsx). The folder name must be movies to match the URL segment.
- Check that layout.tsx and page.tsx are both directly inside the app/movies/ folder, not nested deeper.
- Verify your import path uses @/app/lib/movies exactly. A typo in the path will cause a build error in the terminal.
Help me debug my movies page
✔️ Awesome, I've got everything!
Nice. Make sure both files are saved before continuing.
ⓧ I'd like to double check the full code
import Link from "next/link"
import { movies } from "@/app/lib/movies"
export default function MoviesPage() {
return (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{movies.map((movie) => (
<Link
key={movie.id}
href={`/movies/${movie.id}`}
className="block p-4 bg-white rounded-lg border border-gray-200 hover:shadow-md transition-shadow"
>
<h2 className="text-lg font-semibold text-gray-900">{movie.title}</h2>
<p className="text-sm text-gray-500 mt-1">
{movie.year} · {movie.genre}
</p>
<p className="text-sm text-yellow-600 mt-1">
Rating: {movie.rating}/10
</p>
</Link>
))}
</div>
)
}
Verify nested layout behavior
The key insight with nested layouts is that they only mount when the URL matches their route segment. The root layout is always visible, but the movies layout appears and disappears based on where you are.
- Click MovieApp in the navigation bar to go back to the home page.
- Notice that the "Browse Movies" section header disappears. The root nav stays, but the movies layout is gone because you left the /movies segment.
- Click Movies in the nav to return to the listing.
- The "Browse Movies" header reappears above the grid of cards. Both the root layout and the movies layout are active at the same time.
What is happening under the hood?
Next.js nests layouts automatically based on the folder hierarchy. When the URL is /movies, the framework renders the root layout first, then slots the movies layout inside the root's {children}, then slots the movies page inside the movies layout's {children}.
When you navigate away, the movies layout unmounts but the root layout stays in place. This is why you put persistent UI (like navigation) in the root layout and section-specific UI (like the "Browse Movies" header) in a nested layout.
Your movies section is live with a nested layout that appears only when browsing /movies routes. Next up, you will create dynamic route pages so each movie card links to its own detail page.
Add Dynamic Movie Detail Pages
Your movies listing page is alive with clickable cards that link to URLs like /movies/1 and /movies/2. Right now, clicking a card navigates to a URL that does not exist yet.
Next.js solves this with dynamic routes. A folder named with square brackets (like [id]) tells the router to capture any value in that URL segment and pass it to the page as a parameter. In this step, you will build a detail page that reads the movie ID from the URL and displays that movie's information.
In this step, get ready to:
- Create a dynamic route page that renders individual movie details.
- Add error handling for invalid movie IDs using notFound().
- Test navigation between the listing and detail pages.
Create the dynamic route page
In Next.js, a folder named [id] inside app/movies/ creates a dynamic segment. When a user visits /movies/3, Next.js passes 3 as a parameter to the page component inside that folder.
What is a dynamic route?
Static routes like /about map to one fixed page. Dynamic routes like /movies/[id] map to many pages from a single template. The value in the URL (1, 2, 3) becomes a parameter your code reads at render time.
- In the VS Code Explorer sidebar, right-click the app/movies folder.
- Click New File.
- Type [id]/page.tsx as the filename and press Enter. VS Code creates both the [id] folder and the page.tsx file inside it.
- Paste the following code into app/movies/[id]/page.tsx:
import { notFound } from "next/navigation"
import { getMovieById } from "@/app/lib/movies"
// async because params is a Promise in Next.js 16
export default async function MovieDetailPage({
params,
}: {
params: Promise<{ id: string }>
}) {
// Unwrap the params Promise to get the dynamic segment value
const { id } = await params
const movie = getMovieById(id)
// If no movie matches the ID, show a 404 page
if (!movie) {
notFound()
}
return (
<div className="max-w-2xl">
<div className="flex items-start justify-between">
<div>
<h2 className="text-2xl font-bold text-gray-900">{movie.title}</h2>
<p className="text-gray-500 mt-1">
{movie.year} · {movie.genre}
</p>
</div>
</div>
<div className="mt-4 bg-white p-6 rounded-lg border border-gray-200">
<p className="text-yellow-600 font-medium mb-3">
Rating: {movie.rating}/10
</p>
<p className="text-gray-700 leading-relaxed">{movie.description}</p>
</div>
</div>
)
}
What does this code do?
- The function is async because in Next.js 16, params is a Promise. You must await it before reading its values.
- const { id } = await params unwraps the Promise and pulls out the id segment from the URL.
- getMovieById(id) searches your movies array. If no match is found, notFound() triggers Next.js to render a 404 page.
- The return JSX renders the movie title, year, genre, rating, and description in a styled card layout.
- Save app/movies/[id]/page.tsx.
- Switch back to your browser at http://localhost:3000/movies.
- Click the The Shawshank Redemption card. You should see the movie's title, year, genre, rating, and full description displayed on a detail page at /movies/1.
Don't see the movie details?
- Make sure you saved page.tsx inside the [id] folder, not directly inside app/movies/. The file path must be app/movies/[id]/page.tsx.
- Check that the folder is literally named [id] with square brackets included. Without them, Next.js treats it as a static route.
- Confirm your dev server is still running in the terminal. If it stopped, run npm run dev again.
Why isn't my dynamic route page rendering?
✔️ Awesome, I've got everything!
Great. Make sure you have saved your file before moving on.
ⓧ I'd like to double check the full code
Here is the complete app/movies/[id]/page.tsx file:
import { notFound } from "next/navigation"
import { getMovieById } from "@/app/lib/movies"
export default async function MovieDetailPage({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id } = await params
const movie = getMovieById(id)
if (!movie) {
notFound()
}
return (
<div className="max-w-2xl">
<div className="flex items-start justify-between">
<div>
<h2 className="text-2xl font-bold text-gray-900">{movie.title}</h2>
<p className="text-gray-500 mt-1">
{movie.year} · {movie.genre}
</p>
</div>
</div>
<div className="mt-4 bg-white p-6 rounded-lg border border-gray-200">
<p className="text-yellow-600 font-medium mb-3">
Rating: {movie.rating}/10
</p>
<p className="text-gray-700 leading-relaxed">{movie.description}</p>
</div>
</div>
)
}
Understand how missing movies are handled
Your detail page already handles the case where someone visits an invalid URL like /movies/999. The notFound() function from next/navigation tells Next.js to stop rendering the page and show a 404 instead.
How does notFound() work?
When getMovieById(id) returns undefined (no movie with that ID exists), calling notFound() immediately halts the component. Next.js then looks for the closest not-found.tsx file in the route hierarchy.
Since you have not created a custom not-found.tsx yet, Next.js falls back to its built-in default 404 page. You will create custom not-found pages in the Secret Mission.
Test the dynamic route
- Return to http://localhost:3000/movies in your browser.
- Click different movie cards. Each one should navigate to its own URL (/movies/1, /movies/2, /movies/3, etc.) and display that movie's details.
- Notice that the Browse Movies header from the nested movies layout persists on every detail page. The root nav bar also stays in place.
- Type http://localhost:3000/movies/999 directly into your browser address bar and press Enter.
- You should see a 404 page because no movie with ID 999 exists in your data array.
Why does the nested layout still show?
The /movies/[id] route is a child of /movies, so app/movies/layout.tsx wraps it automatically. Nested layouts in Next.js wrap all pages and child layouts within their route segment.
Each movie now has its own page, driven by a single dynamic route template. Next up, you will add interactivity with a Client Component that lets users favorite movies.
Add a Client Component (Favorites Button)
Your movie detail pages look great, but they are completely static. Every component you have built so far is a Server Component. The server renders the HTML and sends it to the browser with zero JavaScript attached.
In this step, you will add a "Favorites" button that responds to clicks. Because click handlers and state require browser JavaScript, this button must be a Client Component. You will see how Next.js lets you compose a small interactive island inside an otherwise server-rendered page.
In this step, get ready to:
- Create a Client Component with interactive state.
- Compose the Client Component inside a Server Component page.
- Verify that only the interactive piece ships JavaScript to the browser.
Create the FavoriteButton component
The "use client" directive at the top of a file tells Next.js to include that file in the browser's JavaScript bundle. Without it, the component runs only on the server and cannot use hooks like useState.
- In the VS Code Explorer sidebar, right-click the app/movies/[id] folder.
- Select New File and name it favorite-button.tsx.
- Paste the following code into favorite-button.tsx:
"use client"
import { useState } from "react"
export default function FavoriteButton({
movieTitle,
}: {
movieTitle: string
}) {
// Track whether the user has favorited this movie
const [isFavorite, setIsFavorite] = useState(false)
return (
<button
onClick={() => setIsFavorite(!isFavorite)}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
isFavorite
? "bg-red-100 text-red-700 hover:bg-red-200"
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
}`}
aria-label={
isFavorite
? `Remove ${movieTitle} from favorites`
: `Add ${movieTitle} to favorites`
}
>
{isFavorite ? "♥ Favorited" : "♡ Favorite"}
</button>
)
}
What does this code do?
"use client" at the very top marks this file as a Client Component. Next.js will bundle it into the browser JavaScript so hooks and event handlers work.
useState(false) creates a boolean that flips each time the button is clicked. The button text and styling change based on that boolean. The aria-label ensures screen readers announce the correct action.
- Save favorite-button.tsx.
You will not see this button on any page yet. The next substep wires it into the movie detail page.
Seeing a red squiggly under useState?
Make sure "use client" is the very first line of the file. It must come before any imports. If it is on line 2 or lower, Next.js treats the file as a Server Component and useState is not allowed.
Help me fix a "useState is not allowed in a Server Component" error.
✔️ Awesome, I've got everything!
Great. Double check you have saved the file before moving on.
ⓧ I'd like to double check the full code
"use client"
import { useState } from "react"
export default function FavoriteButton({
movieTitle,
}: {
movieTitle: string
}) {
const [isFavorite, setIsFavorite] = useState(false)
return (
<button
onClick={() => setIsFavorite(!isFavorite)}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
isFavorite
? "bg-red-100 text-red-700 hover:bg-red-200"
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
}`}
aria-label={
isFavorite
? `Remove ${movieTitle} from favorites`
: `Add ${movieTitle} to favorites`
}
>
{isFavorite ? "♥ Favorited" : "♡ Favorite"}
</button>
)
}
Compose Server and Client Components
The movie detail page itself stays as a Server Component. You will import the Client Component into it and pass data down as a prop. This is the composition pattern: the server fetches data, the client handles interactivity.
- In VS Code, open app/movies/[id]/page.tsx.
- Find the import lines at the top of the file:
import { notFound } from "next/navigation"
import { getMovieById } from "@/app/lib/movies"
- Add a new import for FavoriteButton directly below the existing imports, so all three lines look like this:
import { notFound } from "next/navigation"
import { getMovieById } from "@/app/lib/movies"
import FavoriteButton from "./favorite-button"
- Now scroll down to the JSX. Find the closing </div> that wraps the title and genre:
<div>
<h2 className="text-2xl font-bold text-gray-900">{movie.title}</h2>
<p className="text-gray-500 mt-1">
{movie.year} · {movie.genre}
</p>
</div>
- Add the FavoriteButton component directly after that closing </div>, passing movie.title as the movieTitle prop:
<div>
<h2 className="text-2xl font-bold text-gray-900">{movie.title}</h2>
<p className="text-gray-500 mt-1">
{movie.year} · {movie.genre}
</p>
</div>
<FavoriteButton movieTitle={movie.title} />
Why pass movieTitle as a prop?
The detail page is a Server Component that already has the movie data. The Client Component does not fetch data on its own. Instead, the server passes serializable props (a plain string) across the boundary so the button can reference the movie title in its aria-label.
- Save page.tsx.
- Switch to your browser and navigate to http://localhost:3000/movies/1.
- You should see a gray "Favorite" button in the top-right area of the movie details. Click it. The button text should change to "Favorited" with a red background.
Don't see the button?
Check that the import path is ./favorite-button (relative, starts with a dot). An absolute path like @/app/movies/[id]/favorite-button will also work, but a bare favorite-button without the dot will fail.
Also confirm the file is named exactly favorite-button.tsx (not FavoriteButton.tsx). The import must match the filename.
Help me debug why my FavoriteButton is not appearing on the movie detail page.
✔️ Awesome, I've got everything!
Great. Make sure you have saved both files before continuing.
ⓧ I'd like to double check the full code
import { notFound } from "next/navigation"
import { getMovieById } from "@/app/lib/movies"
import FavoriteButton from "./favorite-button"
export default async function MovieDetailPage({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id } = await params
const movie = getMovieById(id)
if (!movie) {
notFound()
}
return (
<div className="max-w-2xl">
<div className="flex items-start justify-between">
<div>
<h2 className="text-2xl font-bold text-gray-900">{movie.title}</h2>
<p className="text-gray-500 mt-1">
{movie.year} · {movie.genre}
</p>
</div>
<FavoriteButton movieTitle={movie.title} />
</div>
<div className="mt-4 bg-white p-6 rounded-lg border border-gray-200">
<p className="text-yellow-600 font-medium mb-3">
Rating: {movie.rating}/10
</p>
<p className="text-gray-700 leading-relaxed">{movie.description}</p>
</div>
</div>
)
}
Verify the server/client boundary
Only the file marked with "use client" (and its imports) ship JavaScript to the browser. The parent page stays a Server Component with zero client-side JS. You can confirm this in your browser's DevTools.
- In your browser, navigate to http://localhost:3000/movies (the listing page).
- Open DevTools by pressing Cmd+Option+I (macOS) or Ctrl+Shift+I (Windows).
- Click the Network tab.
- Click on any movie card to navigate to a detail page.
- Look at the JavaScript files that load. The FavoriteButton code is fetched only when you land on a detail page. The movies listing page does not load it.
Why does this matter?
By keeping most of your pages as Server Components, you send less JavaScript to the browser. Pages load faster and remain lightweight. You only opt into client JavaScript where you truly need interactivity.
This is the core insight of the App Router's component model. The server does the heavy lifting. The client handles only the interactive bits.
- Click the Favorite button a few times to confirm it toggles between the unfavorited and favorited states without a full page reload.
Secret mission
Custom Not Found Pages
Right now, invalid URLs show a generic Next.js 404 page. In this secret mission, you'll create custom not-found pages at two levels of your route hierarchy. The global page catches any unknown URL, while a movie-specific page provides a tailored experience when someone visits an invalid movie ID.
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, so there are no ongoing costs.
Resources you used:
- The movie-collection project folder on your Desktop.
- The Next.js dev server running at http://localhost:3000.
Keep everything running
No action needed. Choose this if you are still actively building or plan to continue with Project 2, which builds on this app.
- Your dev server stays running at http://localhost:3000.
- Your movie-collection folder remains on your Desktop.
Pause - I'll come back to this later
Stop the dev server to free up your terminal, but keep your project files for later.
- Switch to the terminal where the dev server is running.
- Press Ctrl+C to stop the server.
- Your movie-collection folder stays on your Desktop. When you are ready to resume, open the folder in VS Code and run npm run dev again.
Delete - I don't want to use this again
Remove all project files and start fresh.
- Switch to the terminal where the 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-collection
On Windows, run this instead:
Remove-Item -Recurse -Force ~\Desktop\movie-collection
- Confirm the folder is gone by running:
ls ~/Desktop
You should no longer see movie-collection in the output.
That's a Wrap!
That's a Wrap!
Nice work! You just built a multi-page movie collection app with Next.js 16 and the App Router.
You've learned how to:
- Organize routes using file-system routing, with a root layout providing persistent navigation via the Link component.
- Build nested layouts that wrap only a specific route segment and its children, while the root layout stays mounted across all pages.
- Implement dynamic routes with async params and compose Server Components with Client Components using the "use client" directive only where interactivity is needed.
- Secret Mission: Created custom not-found.tsx pages at both global and route-segment levels to handle 404 errors gracefully.
Ready to quiz yourself?