Build a Movie Watchlist App with Angular
Build a multi-page Angular app with signals, routing, and services.
Introduction
30 Second Summary
Everyone has a mental list of movies they want to watch. Turning that into a real app is a perfect way to learn how modern web frameworks handle pages, state, and navigation.
In this project, you will build a Movie Watchlist application using Angular v22. You'll create a multi-page app where you can browse a catalog of movies, view individual movie details, and manage a personal watchlist.
What You'll Build
A single-page application built with Angular v22 that features a movie catalog, detail pages with route parameters, and a shared watchlist managed through Signals and services.
By the end of this project, you'll have:
- A movie catalog page displaying a grid of movie cards with titles, genres, and ratings.
- A movie detail page accessed via route parameters that shows full movie information and a watchlist toggle.
- A shared service using Signals and dependency injection to manage watchlist state across components.
- Secret Mission: Add a filter feature that lets users search movies by title and filter by genre.
Are there any prerequisites?
You'll need basic HTML, CSS, and TypeScript knowledge. Familiarity with the command line is also helpful.
Set Up Your Angular Environment
You have your tools ready. Now it's time to create the Angular project that will become your movie watchlist app.
In this step, you'll scaffold a new Angular v22 project, start the development server, and explore how Angular organizes a standalone application.
In this step, get ready to:
- Confirm your development tools are ready.
- Create a new Angular project with routing enabled.
- Explore the project structure in VS Code.
Confirm your development tools are ready
Before creating the project, confirm that Node.js and the Angular CLI are at the right versions.
- Open your terminal by pressing Cmd+Space (macOS) or the Windows key (Windows), typing Terminal, and pressing Enter.
- Check your Node.js version by running this command:
node --version
- You should see v24.15.0 or higher.
- Check your Angular CLI version by running:
ng version
- You should see Angular CLI 22.x in the output.
Seeing a different version or command not found?
If node --version shows a version below v24.15.0, download the latest v24 LTS from https://nodejs.org or run nvm install 24 if you use nvm.
If ng version is not recognized, install the Angular CLI globally by running:
npm install -g @angular/cli
help me fix my Node.js or Angular CLI installation
Create the project
Angular CLI includes a project scaffolding command that generates all the boilerplate files you need. You'll use it to create a project with routing pre-configured.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create the Angular project by running this command:
ng new movie-watchlist --routing --style=css --file-name-style-guide=2016
What does this command do?
- ng new movie-watchlist creates a new Angular project in a folder called movie-watchlist.
- --routing sets up the Angular Router so you can navigate between pages.
- --style=css tells Angular to use plain CSS for component styles.
- --file-name-style-guide=2016 uses the traditional file naming convention (e.g. app.component.ts) which is clearer for learning and more common in existing codebases.
The CLI may prompt you about optional features. Accept the defaults by pressing Enter for each prompt.
- Once the scaffold finishes, move into the project folder by running:
cd movie-watchlist
- Start the development server by running:
npm start
- Open http://localhost:4200 in your browser. You should see the default Angular welcome page.
Don't see the welcome page?
Make sure the terminal shows a successful compilation message before opening the browser. If port 4200 is already in use, the CLI will suggest a different port in its output.
If you see a connection error, wait a few seconds for the server to finish compiling and try refreshing.
help me troubleshoot my Angular dev server
Open in VS Code and explore the structure
Angular v22 uses standalone components by default. There is no app.module.ts file. Instead, the app.config.ts file holds your application configuration.
- Open a new terminal tab or window (keep the dev server running in the original one).
- Open the project in VS Code by running this command from inside the movie-watchlist folder:
code .
- In the Explorer sidebar, expand the src/app folder. You should see these key files:
- src/main.ts bootstraps the application by launching the root component.
- src/app/app.config.ts holds application-level providers like the router.
- src/app/app.routes.ts defines all the routes (currently empty).
- src/app/app.component.ts is the root component that Angular renders first.
What is a standalone component?
In Angular v22, every component is standalone by default. This means each component declares its own dependencies in an imports array, rather than relying on a shared NgModule. This makes components more self-contained and easier to understand.
The app.config.ts file replaces what used to be app.module.ts. It provides application-wide services (like the router) without needing a module class.
✔️ Awesome, I've got everything!
Great. Make sure the dev server is still running in your terminal and your browser shows the Angular welcome page at http://localhost:4200.
ⓧ I'd like to double check the full code
Here are the key files that Angular CLI generated for you. Compare them to what you see in VS Code.
import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { AppComponent } from './app/app.component';
bootstrapApplication(AppComponent, appConfig)
.catch((err) => console.error(err));
import { ApplicationConfig } from '@angular/core';
import { provideRouter } from '@angular/router';
import { routes } from './app.routes';
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes)
]
};
import { Routes } from '@angular/router';
export const routes: Routes = [];
import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';
@Component({
selector: 'app-root',
standalone: true,
imports: [RouterOutlet],
templateUrl: './app.component.html',
styleUrl: './app.component.css'
})
export class AppComponent {
title = 'movie-watchlist';
}
Your Angular project is scaffolded and running. Next up, you'll build the movie catalog component and display a grid of movie cards on the page.
Build the Movie Catalog Component
Your Angular project is scaffolded, the dev server is running, and VS Code is open. Now it's time to build something visible.
Every app needs data to display. In this step, you'll define what a "movie" looks like in your app, create a catalog of hardcoded movies, and build a component that renders them as a grid of cards in the browser.
In this step, get ready to:
- Define a Movie interface and a hardcoded data set.
- Generate and implement a MovieCatalog component with a signal-based data source.
- Wire up routing and a navigation bar so the catalog displays in the browser.
Create the movie data and interface
Before building a component, you need the data it will display. In Angular, a TypeScript interface defines the shape of your data so the compiler catches mistakes early.
- In VS Code's Explorer sidebar, right-click the src/app folder.
- Select New File.
- Type models/movie.ts as the filename and press Enter. VS Code creates the models folder automatically.
- Paste the following code into movie.ts:
export interface Movie {
id: number;
title: string;
year: number;
genre: string;
description: string;
imageUrl: string;
rating: number;
}
What does this code do?
This interface tells TypeScript that every movie object must have these seven properties with specific types. If you accidentally misspell title or assign a string to rating, the compiler flags it immediately.
- Save movie.ts.
Now create the actual movie data. This hardcoded array lets you focus on Angular fundamentals without worrying about HTTP requests.
- Right-click the src/app folder again and select New File.
- Type data/movies.ts as the filename and press Enter.
- Paste the following code into movies.ts:
import { Movie } from '../models/movie';
export const MOVIES: Movie[] = [
{
id: 1,
title: 'The Shawshank Redemption',
year: 1994,
genre: 'Drama',
description: 'Two imprisoned men bond over a number of years, finding solace and eventual redemption through acts of common decency.',
imageUrl: 'https://via.placeholder.com/300x450?text=Shawshank',
rating: 9.3
},
{
id: 2,
title: 'The Dark Knight',
year: 2008,
genre: 'Action',
description: 'When the menace known as the Joker wreaks havoc on Gotham, Batman must accept one of the greatest tests to fight injustice.',
imageUrl: 'https://via.placeholder.com/300x450?text=Dark+Knight',
rating: 9.0
},
{
id: 3,
title: 'Interstellar',
year: 2014,
genre: 'Sci-Fi',
description: 'A team of explorers travel through a wormhole in space in an attempt to ensure humanity\'s survival.',
imageUrl: 'https://via.placeholder.com/300x450?text=Interstellar',
rating: 8.6
},
{
id: 4,
title: 'Pulp Fiction',
year: 1994,
genre: 'Drama',
description: 'The lives of two mob hitmen, a boxer, a gangster, and his wife intertwine in four tales of violence and redemption.',
imageUrl: 'https://via.placeholder.com/300x450?text=Pulp+Fiction',
rating: 8.9
},
{
id: 5,
title: 'The Matrix',
year: 1999,
genre: 'Sci-Fi',
description: 'A computer hacker learns about the true nature of his reality and his role in the war against its controllers.',
imageUrl: 'https://via.placeholder.com/300x450?text=Matrix',
rating: 8.7
},
{
id: 6,
title: 'Gladiator',
year: 2000,
genre: 'Action',
description: 'A former Roman General sets out to exact vengeance against the corrupt emperor who murdered his family.',
imageUrl: 'https://via.placeholder.com/300x450?text=Gladiator',
rating: 8.5
}
];
What does this code do?
The MOVIES constant is an array of six movie objects. Each object follows the Movie interface you just created, so TypeScript validates every property.
The imageUrl fields use placeholder images. The data is hardcoded so you can focus on learning Angular's component patterns without needing an API.
- Save movies.ts.
- Confirm both files exist by checking the Explorer sidebar. You should see src/app/models/movie.ts and src/app/data/movies.ts.
Don't see the new files?
Make sure you created them inside src/app/ (not src/ directly). The import path in movies.ts uses '../models/movie' which only resolves if both folders sit inside src/app/.
If you see a red underline on the import, double-check that your file is named movie.ts (singular, not movies.ts) inside the models folder.
Still stuck? Help me fix my movie data file imports.
✔️ Awesome, I've got everything!
Great. Make sure both files are saved before moving on.
ⓧ I'd like to double check the full code
export interface Movie {
id: number;
title: string;
year: number;
genre: string;
description: string;
imageUrl: string;
rating: number;
}
import { Movie } from '../models/movie';
export const MOVIES: Movie[] = [
{
id: 1,
title: 'The Shawshank Redemption',
year: 1994,
genre: 'Drama',
description: 'Two imprisoned men bond over a number of years, finding solace and eventual redemption through acts of common decency.',
imageUrl: 'https://via.placeholder.com/300x450?text=Shawshank',
rating: 9.3
},
{
id: 2,
title: 'The Dark Knight',
year: 2008,
genre: 'Action',
description: 'When the menace known as the Joker wreaks havoc on Gotham, Batman must accept one of the greatest tests to fight injustice.',
imageUrl: 'https://via.placeholder.com/300x450?text=Dark+Knight',
rating: 9.0
},
{
id: 3,
title: 'Interstellar',
year: 2014,
genre: 'Sci-Fi',
description: 'A team of explorers travel through a wormhole in space in an attempt to ensure humanity\'s survival.',
imageUrl: 'https://via.placeholder.com/300x450?text=Interstellar',
rating: 8.6
},
{
id: 4,
title: 'Pulp Fiction',
year: 1994,
genre: 'Drama',
description: 'The lives of two mob hitmen, a boxer, a gangster, and his wife intertwine in four tales of violence and redemption.',
imageUrl: 'https://via.placeholder.com/300x450?text=Pulp+Fiction',
rating: 8.9
},
{
id: 5,
title: 'The Matrix',
year: 1999,
genre: 'Sci-Fi',
description: 'A computer hacker learns about the true nature of his reality and his role in the war against its controllers.',
imageUrl: 'https://via.placeholder.com/300x450?text=Matrix',
rating: 8.7
},
{
id: 6,
title: 'Gladiator',
year: 2000,
genre: 'Action',
description: 'A former Roman General sets out to exact vengeance against the corrupt emperor who murdered his family.',
imageUrl: 'https://via.placeholder.com/300x450?text=Gladiator',
rating: 8.5
}
];
Generate and implement the MovieCatalog component
Angular's CLI can generate component files for you, saving you from creating each file manually. You'll then replace the placeholder code with your catalog implementation.
- Generate the MovieCatalog component by running this command in your terminal (make sure you're inside the movie-watchlist folder):
ng generate component movie-catalog --file-name-style-guide=2016
- You should see output confirming four new files were created inside src/app/movie-catalog/.
What does this command do?
The ng generate component command creates a TypeScript file, an HTML template, a CSS file, and a spec (test) file for your component. The --file-name-style-guide=2016 flag uses the traditional naming convention (movie-catalog.component.ts) which is easier to identify in a file tree.
Seeing 'ng: command not found'?
Make sure you installed the Angular CLI globally in Step 1 with npm install -g @angular/cli. Try closing and reopening your terminal, then run ng version to confirm.
Also confirm your terminal is inside the movie-watchlist project folder, not a parent directory.
Need more help? Help me fix my ng generate command.
Now replace the generated placeholder code with your catalog implementation. A signal holds the movie data reactively, so Angular automatically updates the template whenever the data changes.
- Open src/app/movie-catalog/movie-catalog.component.ts and replace its entire contents with this code:
import { Component, signal } from '@angular/core';
import { RouterLink } from '@angular/router';
import { MOVIES } from '../data/movies';
import { Movie } from '../models/movie';
@Component({
selector: 'app-movie-catalog',
standalone: true,
imports: [RouterLink],
templateUrl: './movie-catalog.component.html',
styleUrl: './movie-catalog.component.css'
})
export class MovieCatalogComponent {
// Hold the movie array in a signal for reactive updates
movies = signal<Movie[]>(MOVIES);
}
What does this code do?
- The imports: [RouterLink] array tells Angular that this component's template uses the RouterLink directive (for the "View Details" links).
- The movies = signal<Movie[]>(MOVIES) line wraps the movie data in a signal. Signals are Angular's reactive primitive. When signal data changes, any template reading it re-renders automatically.
- The standalone: true property means this component manages its own dependencies through the imports array instead of relying on a shared module.
- Save movie-catalog.component.ts.
- Open src/app/movie-catalog/movie-catalog.component.html and replace its entire contents with this template:
<h1>Movie Catalog</h1>
<div class="movie-grid">
@for (movie of movies(); track movie.id) {
<div class="movie-card">
<img [src]="movie.imageUrl" [alt]="movie.title">
<h3>{{ movie.title }}</h3>
<p>{{ movie.year }} | {{ movie.genre }}</p>
<p class="rating">Rating: {{ movie.rating }}/10</p>
<a [routerLink]="['/movie', movie.id]" class="btn">View Details</a>
</div>
}
</div>
What does this template do?
- The @for block is Angular's native control flow for looping. It iterates over movies() (calling the signal to read its value) and renders one card per movie.
- The track movie.id clause tells Angular how to identify each item so it can efficiently update the DOM when the list changes.
- The [routerLink]="['/movie', movie.id]" builds a URL like /movie/3 for each card. You'll create the detail page that handles this URL in the next step.
- Save movie-catalog.component.html.
- Open src/app/movie-catalog/movie-catalog.component.css and replace its contents with this stylesheet:
h1 {
text-align: center;
margin-bottom: 24px;
}
.movie-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 24px;
padding: 16px;
}
.movie-card {
border: 1px solid #ddd;
border-radius: 8px;
padding: 16px;
text-align: center;
transition: box-shadow 0.2s;
}
.movie-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.movie-card img {
width: 100%;
height: 200px;
object-fit: cover;
border-radius: 4px;
}
.movie-card h3 {
margin: 12px 0 4px;
}
.rating {
font-weight: bold;
color: #f39c12;
}
.btn {
display: inline-block;
margin-top: 8px;
padding: 8px 16px;
background-color: #3498db;
color: white;
text-decoration: none;
border-radius: 4px;
}
.btn:hover {
background-color: #2980b9;
}
What do these styles do?
The .movie-grid uses CSS Grid with auto-fill to create a responsive layout. Cards automatically wrap to new rows as the browser window shrinks.
Each .movie-card gets a subtle hover shadow so users can tell the cards are interactive.
- Save movie-catalog.component.css.
- You won't see changes in the browser yet. The component exists but Angular doesn't know when to display it. The next substep wires it to a URL route.
✔️ Awesome, I've got everything!
Great. Make sure all three component files are saved before continuing.
ⓧ I'd like to double check the full code
import { Component, signal } from '@angular/core';
import { RouterLink } from '@angular/router';
import { MOVIES } from '../data/movies';
import { Movie } from '../models/movie';
@Component({
selector: 'app-movie-catalog',
standalone: true,
imports: [RouterLink],
templateUrl: './movie-catalog.component.html',
styleUrl: './movie-catalog.component.css'
})
export class MovieCatalogComponent {
movies = signal<Movie[]>(MOVIES);
}
<h1>Movie Catalog</h1>
<div class="movie-grid">
@for (movie of movies(); track movie.id) {
<div class="movie-card">
<img [src]="movie.imageUrl" [alt]="movie.title">
<h3>{{ movie.title }}</h3>
<p>{{ movie.year }} | {{ movie.genre }}</p>
<p class="rating">Rating: {{ movie.rating }}/10</p>
<a [routerLink]="['/movie', movie.id]" class="btn">View Details</a>
</div>
}
</div>
h1 {
text-align: center;
margin-bottom: 24px;
}
.movie-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 24px;
padding: 16px;
}
.movie-card {
border: 1px solid #ddd;
border-radius: 8px;
padding: 16px;
text-align: center;
transition: box-shadow 0.2s;
}
.movie-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.movie-card img {
width: 100%;
height: 200px;
object-fit: cover;
border-radius: 4px;
}
.movie-card h3 {
margin: 12px 0 4px;
}
.rating {
font-weight: bold;
color: #f39c12;
}
.btn {
display: inline-block;
margin-top: 8px;
padding: 8px 16px;
background-color: #3498db;
color: white;
text-decoration: none;
border-radius: 4px;
}
.btn:hover {
background-color: #2980b9;
}
Wire up the route and display the catalog
Angular's router decides which component to render based on the URL. You need to map the empty path (your homepage) to the MovieCatalog component and update the app shell with a navigation bar.
- Open src/app/app.routes.ts and replace its entire contents with this code:
import { Routes } from '@angular/router';
import { MovieCatalogComponent } from './movie-catalog/movie-catalog.component';
export const routes: Routes = [
// When the URL is just '/', show the catalog
{ path: '', component: MovieCatalogComponent, title: 'Movie Catalog' }
];
What does this code do?
The path: '' matches the root URL (http://localhost:4200). When a user visits this URL, Angular renders MovieCatalogComponent inside the <router-outlet /> placeholder.
The title property automatically sets the browser tab's title when this route is active.
- Save app.routes.ts.
Now update the app's root component to include a navigation bar and the router outlet where page components render.
- Open src/app/app.component.ts and replace its entire contents with:
import { Component } from '@angular/core';
import { RouterLink, RouterOutlet } from '@angular/router';
@Component({
selector: 'app-root',
standalone: true,
imports: [RouterOutlet, RouterLink],
templateUrl: './app.component.html',
styleUrl: './app.component.css'
})
export class AppComponent {
title = 'Movie Watchlist';
}
What changed here?
- You added RouterLink to the import statement and the imports array. This lets the template use routerLink directives for navigation.
- The title property changed to 'Movie Watchlist' to give the app a proper name.
- Save app.component.ts.
- Open src/app/app.component.html and replace its entire contents with this template:
<nav class="navbar">
<a routerLink="/" class="brand">Movie Watchlist</a>
<div class="nav-links">
<a routerLink="/">Catalog</a>
</div>
</nav>
<main>
<router-outlet />
</main>
What does this template do?
The <nav> bar provides links for navigating the app. The routerLink directive handles client-side navigation without a full page reload.
The <router-outlet /> is where Angular injects the component matching the current URL. Right now, visiting / renders your MovieCatalog component here.
- Save app.component.html.
- Open src/app/app.component.css and replace its contents with these styles:
.navbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 24px;
background-color: #2c3e50;
color: white;
}
.brand {
font-size: 1.4em;
font-weight: bold;
color: white;
text-decoration: none;
}
.nav-links a {
color: #ecf0f1;
text-decoration: none;
margin-left: 16px;
padding: 6px 12px;
border-radius: 4px;
}
.nav-links a:hover {
background-color: #34495e;
}
main {
padding: 24px;
}
- Save app.component.css.
- Switch to your browser and visit http://localhost:4200. You should see a dark navigation bar at the top with "Movie Watchlist" on the left and a "Catalog" link on the right.
- Below the navbar, you should see a responsive grid of six movie cards. Each card displays a placeholder image, the movie title, its year and genre, a rating in gold text, and a blue "View Details" link.
Don't see the movie grid?
Check that your dev server is still running. If it stopped, run npm start again from your movie-watchlist folder.
If the page is blank or shows errors, open your browser's DevTools console (Cmd+Option+I on macOS or Ctrl+Shift+I on Windows). Look for red error messages. A common issue is a typo in the import path inside app.routes.ts.
If you see the navbar but no cards, confirm that movie-catalog.component.ts imports MOVIES from '../data/movies' (not '../data/movie').
Still stuck? Help me debug why my movie catalog isn't showing up.
✔️ Awesome, I've got everything!
Great. Make sure you can see all six movie cards and the dark navbar before continuing.
ⓧ I'd like to double check the full code
import { Routes } from '@angular/router';
import { MovieCatalogComponent } from './movie-catalog/movie-catalog.component';
export const routes: Routes = [
{ path: '', component: MovieCatalogComponent, title: 'Movie Catalog' }
];
import { Component } from '@angular/core';
import { RouterLink, RouterOutlet } from '@angular/router';
@Component({
selector: 'app-root',
standalone: true,
imports: [RouterOutlet, RouterLink],
templateUrl: './app.component.html',
styleUrl: './app.component.css'
})
export class AppComponent {
title = 'Movie Watchlist';
}
<nav class="navbar">
<a routerLink="/" class="brand">Movie Watchlist</a>
<div class="nav-links">
<a routerLink="/">Catalog</a>
</div>
</nav>
<main>
<router-outlet />
</main>
.navbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 24px;
background-color: #2c3e50;
color: white;
}
.brand {
font-size: 1.4em;
font-weight: bold;
color: white;
text-decoration: none;
}
.nav-links a {
color: #ecf0f1;
text-decoration: none;
margin-left: 16px;
padding: 6px 12px;
border-radius: 4px;
}
.nav-links a:hover {
background-color: #34495e;
}
main {
padding: 24px;
}
Your catalog is live. Six movies displayed in a responsive grid, all powered by a signal and Angular's native @for control flow. Next up, you'll build a detail page so users can click into any movie and see its full description.
Add the Movie Detail Page
Your movie catalog is live and showing a grid of clickable cards. The next step is giving each movie its own dedicated page.
When a user clicks View Details, the app needs to read a movie's ID from the URL, look up that movie, and display its full information. This is how real-world apps work. You will use Angular Router's route parameters to make this happen.
In this step, get ready to:
- Generate the MovieDetail component and WatchlistService.
- Build the detail page template with watchlist functionality.
- Connect the route so clicking a movie card navigates to its detail page.
Generate the MovieDetail component and WatchlistService
Before you can build the detail page, you need two new pieces. The component itself will render a single movie's information. The service will manage the watchlist state that the detail page will interact with.
- Generate the MovieDetail component by running this command in your terminal:
ng generate component movie-detail --file-name-style-guide=2016
- Generate the WatchlistService by running:
ng generate service services/watchlist --file-name-style-guide=2016
What did these commands create?
The first command created a movie-detail folder inside src/app/ with three files: the component class, its template, and its stylesheet.
The second command created a services folder inside src/app/ with watchlist.service.ts. This service will hold your watchlist data and be shared across any component that needs it.
Now implement the WatchlistService logic. This service uses Signals to hold an array of saved movies and exposes methods to add, remove, and check movies.
- Open src/app/services/watchlist.service.ts and replace the generated content with the following code:
import { Injectable, signal, computed } from '@angular/core';
import { Movie } from '../models/movie';
@Injectable({
providedIn: 'root'
})
export class WatchlistService {
// Private signal holding the array of saved movies
private watchlistItems = signal<Movie[]>([]);
// Read-only computed signal for components to consume
watchlist = computed(() => this.watchlistItems());
// Add a movie only if it is not already in the list
addToWatchlist(movie: Movie): void {
if (!this.isInWatchlist(movie.id)) {
this.watchlistItems.update(items => [...items, movie]);
}
}
// Remove a movie by filtering it out
removeFromWatchlist(movieId: number): void {
this.watchlistItems.update(items => items.filter(item => item.id !== movieId));
}
// Check whether a movie ID exists in the watchlist
isInWatchlist(movieId: number): boolean {
return this.watchlistItems().some(item => item.id === movieId);
}
}
What does this code do?
- watchlistItems is a private signal that stores the current list of saved movies. Only methods inside this service can modify it.
- watchlist is a computed signal that exposes a read-only view of the list to any component.
- addToWatchlist uses .update() to spread the existing items and append the new movie. It checks for duplicates first.
- removeFromWatchlist filters out the movie by ID, producing a new array reference that triggers signal updates.
- Save watchlist.service.ts.
Seeing a compile error?
Make sure the import path '../models/movie' is correct. If your services folder is inside src/app/, the relative path goes up one level to reach the models folder.
help me fix this WatchlistService import error
Build the MovieDetail component
The detail component reads the movie ID from the URL, looks up the matching movie, and lets users toggle it on or off the watchlist.
- Open src/app/movie-detail/movie-detail.component.ts and replace its content with:
import { Component, inject } from '@angular/core';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { MOVIES } from '../data/movies';
import { Movie } from '../models/movie';
import { WatchlistService } from '../services/watchlist.service';
@Component({
selector: 'app-movie-detail',
standalone: true,
imports: [RouterLink],
templateUrl: './movie-detail.component.html',
styleUrl: './movie-detail.component.css'
})
export class MovieDetailComponent {
// Inject the route to read URL parameters
private route = inject(ActivatedRoute);
// Inject the watchlist service to manage saved movies
private watchlistService = inject(WatchlistService);
movie: Movie | undefined;
constructor() {
// Read the 'id' parameter from the URL and find the matching movie
const id = Number(this.route.snapshot.paramMap.get('id'));
this.movie = MOVIES.find(m => m.id === id);
}
// Getter that checks if the current movie is in the watchlist
get isInWatchlist(): boolean {
return this.movie ? this.watchlistService.isInWatchlist(this.movie.id) : false;
}
// Toggle the movie in or out of the watchlist
toggleWatchlist(): void {
if (!this.movie) return;
if (this.isInWatchlist) {
this.watchlistService.removeFromWatchlist(this.movie.id);
} else {
this.watchlistService.addToWatchlist(this.movie);
}
}
}
What does this code do?
- inject(ActivatedRoute) gives the component access to the current route's parameters. This is Angular's dependency injection in action.
- this.route.snapshot.paramMap.get('id') extracts the id value from the URL (e.g., /movie/3 gives you '3').
- MOVIES.find() searches the hardcoded array for a movie with a matching ID.
- isInWatchlist is a getter that re-evaluates each time the template checks it, keeping the button label in sync.
Next, build the HTML template that displays the movie's full details.
- Open src/app/movie-detail/movie-detail.component.html and replace its content with:
@if (movie) {
<div class="movie-detail">
<img [src]="movie.imageUrl" [alt]="movie.title">
<div class="movie-info">
<h1>{{ movie.title }}</h1>
<p class="meta">{{ movie.year }} | {{ movie.genre }} | Rating: {{ movie.rating }}/10</p>
<p class="description">{{ movie.description }}</p>
<button (click)="toggleWatchlist()" class="btn">
@if (isInWatchlist) {
Remove from Watchlist
} @else {
Add to Watchlist
}
</button>
<a routerLink="/" class="btn secondary">Back to Catalog</a>
</div>
</div>
} @else {
<div class="not-found">
<h1>Movie Not Found</h1>
<p>The movie you are looking for does not exist.</p>
<a routerLink="/" class="btn">Back to Catalog</a>
</div>
}
What does this template do?
- @if (movie) conditionally renders the detail view. If the ID does not match any movie, the @else block shows a "Movie Not Found" message.
- (click)="toggleWatchlist()" binds the button click to the method in your component class.
- The nested @if (isInWatchlist) toggles the button text between "Remove from Watchlist" and "Add to Watchlist".
- routerLink="/" navigates back to the catalog page (the empty path route).
Finally, add styles to lay out the detail page with the image on the left and info on the right.
- Open src/app/movie-detail/movie-detail.component.css and replace its content with:
.movie-detail {
display: flex;
gap: 32px;
padding: 24px;
max-width: 800px;
margin: 0 auto;
}
.movie-detail img {
width: 300px;
height: 450px;
object-fit: cover;
border-radius: 8px;
}
.movie-info h1 {
margin-bottom: 8px;
}
.meta {
color: #666;
margin-bottom: 16px;
}
.description {
line-height: 1.6;
margin-bottom: 24px;
}
.btn {
display: inline-block;
padding: 10px 20px;
background-color: #27ae60;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
text-decoration: none;
margin-right: 8px;
font-size: 14px;
}
.btn:hover {
background-color: #219a52;
}
.btn.secondary {
background-color: #95a5a6;
}
.btn.secondary:hover {
background-color: #7f8c8d;
}
.not-found {
text-align: center;
padding: 48px;
}
What do these styles do?
The .movie-detail container uses flexbox to place the poster image beside the text content. The .btn class styles the watchlist and back buttons with green and gray colors respectively.
The .not-found class centers the error message for invalid IDs.
- Save all three files: movie-detail.component.ts, movie-detail.component.html, and movie-detail.component.css.
Set up the route and verify navigation
The component is ready, but Angular does not know which URL should load it. You need to register a parameterized route that maps /movie/:id to your new component.
- Open src/app/app.routes.ts and replace its content with:
import { Routes } from '@angular/router';
import { MovieCatalogComponent } from './movie-catalog/movie-catalog.component';
import { MovieDetailComponent } from './movie-detail/movie-detail.component';
export const routes: Routes = [
{ path: '', component: MovieCatalogComponent, title: 'Movie Catalog' },
{ path: 'movie/:id', component: MovieDetailComponent, title: 'Movie Details' },
{ path: '**', redirectTo: '' }
];
What does this code do?
- path: 'movie/:id' defines a route with a dynamic segment. The :id part becomes a route parameter that the component reads at runtime.
- title: 'Movie Details' automatically updates the browser tab title when this route is active.
- path: '**' is a wildcard route. It catches any URL that does not match another route and redirects to the catalog.
- Save app.routes.ts.
- Return to your browser at http://localhost:4200.
- Click View Details on any movie card.
You should see the movie's detail page with its title, year, genre, rating, description, and an "Add to Watchlist" button. Clicking Back to Catalog returns you to the grid.
Page is blank after clicking View Details?
- Check that the import path for MovieDetailComponent in app.routes.ts matches the actual folder name: './movie-detail/movie-detail.component'.
- Verify that the route movie/:id matches the routerLink in your catalog template: [routerLink]="['/movie', movie.id]".
- Look at the terminal running your dev server for any red error messages about missing imports or typos.
Why is my movie detail page blank after clicking View Details?
✔️ Awesome, I've got everything!
Great. Double check you have saved all your files before moving on.
ⓧ I'd like to double check the full code
Here is the full code for each file you created or edited in this step.
import { Injectable, signal, computed } from '@angular/core';
import { Movie } from '../models/movie';
@Injectable({
providedIn: 'root'
})
export class WatchlistService {
private watchlistItems = signal<Movie[]>([]);
watchlist = computed(() => this.watchlistItems());
addToWatchlist(movie: Movie): void {
if (!this.isInWatchlist(movie.id)) {
this.watchlistItems.update(items => [...items, movie]);
}
}
removeFromWatchlist(movieId: number): void {
this.watchlistItems.update(items => items.filter(item => item.id !== movieId));
}
isInWatchlist(movieId: number): boolean {
return this.watchlistItems().some(item => item.id === movieId);
}
}
import { Component, inject } from '@angular/core';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { MOVIES } from '../data/movies';
import { Movie } from '../models/movie';
import { WatchlistService } from '../services/watchlist.service';
@Component({
selector: 'app-movie-detail',
standalone: true,
imports: [RouterLink],
templateUrl: './movie-detail.component.html',
styleUrl: './movie-detail.component.css'
})
export class MovieDetailComponent {
private route = inject(ActivatedRoute);
private watchlistService = inject(WatchlistService);
movie: Movie | undefined;
constructor() {
const id = Number(this.route.snapshot.paramMap.get('id'));
this.movie = MOVIES.find(m => m.id === id);
}
get isInWatchlist(): boolean {
return this.movie ? this.watchlistService.isInWatchlist(this.movie.id) : false;
}
toggleWatchlist(): void {
if (!this.movie) return;
if (this.isInWatchlist) {
this.watchlistService.removeFromWatchlist(this.movie.id);
} else {
this.watchlistService.addToWatchlist(this.movie);
}
}
}
@if (movie) {
<div class="movie-detail">
<img [src]="movie.imageUrl" [alt]="movie.title">
<div class="movie-info">
<h1>{{ movie.title }}</h1>
<p class="meta">{{ movie.year }} | {{ movie.genre }} | Rating: {{ movie.rating }}/10</p>
<p class="description">{{ movie.description }}</p>
<button (click)="toggleWatchlist()" class="btn">
@if (isInWatchlist) {
Remove from Watchlist
} @else {
Add to Watchlist
}
</button>
<a routerLink="/" class="btn secondary">Back to Catalog</a>
</div>
</div>
} @else {
<div class="not-found">
<h1>Movie Not Found</h1>
<p>The movie you are looking for does not exist.</p>
<a routerLink="/" class="btn">Back to Catalog</a>
</div>
}
.movie-detail {
display: flex;
gap: 32px;
padding: 24px;
max-width: 800px;
margin: 0 auto;
}
.movie-detail img {
width: 300px;
height: 450px;
object-fit: cover;
border-radius: 8px;
}
.movie-info h1 {
margin-bottom: 8px;
}
.meta {
color: #666;
margin-bottom: 16px;
}
.description {
line-height: 1.6;
margin-bottom: 24px;
}
.btn {
display: inline-block;
padding: 10px 20px;
background-color: #27ae60;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
text-decoration: none;
margin-right: 8px;
font-size: 14px;
}
.btn:hover {
background-color: #219a52;
}
.btn.secondary {
background-color: #95a5a6;
}
.btn.secondary:hover {
background-color: #7f8c8d;
}
.not-found {
text-align: center;
padding: 48px;
}
import { Routes } from '@angular/router';
import { MovieCatalogComponent } from './movie-catalog/movie-catalog.component';
import { MovieDetailComponent } from './movie-detail/movie-detail.component';
export const routes: Routes = [
{ path: '', component: MovieCatalogComponent, title: 'Movie Catalog' },
{ path: 'movie/:id', component: MovieDetailComponent, title: 'Movie Details' },
{ path: '**', redirectTo: '' }
];
Your detail page is working. Next up, you will build a dedicated Watchlist page where users can see all the movies they have saved and remove any they no longer want.
Create the Watchlist Service and Page
Your Movie Detail page can already add and remove movies from the watchlist using the WatchlistService you created earlier.
Now you need a dedicated page where users can see all their saved movies in one place. In this step, you will build the Watchlist page component and wire it into the app's navigation.
In this step, get ready to:
- Generate a WatchlistComponent that reads from the shared service.
- Build a template that shows saved movies or an empty state.
- Add the watchlist route and a navigation link in the navbar.
Generate the Watchlist component
The Watchlist page needs its own component to display saved movies. The Angular CLI can scaffold the files for you.
- Generate the component by running this command in your terminal:
ng generate component watchlist --file-name-style-guide=2016
- Confirm the files exist by running:
ls src/app/watchlist
- You should see watchlist.component.ts, watchlist.component.html, watchlist.component.css, and watchlist.component.spec.ts in the output.
Command not found?
Make sure you are in the movie-watchlist project folder. The ng command only works inside an Angular project directory.
help me fix an issue with the ng generate command
- Open src/app/watchlist/watchlist.component.ts in VS Code and replace its contents with the following code:
import { Component, inject } from '@angular/core';
import { RouterLink } from '@angular/router';
import { WatchlistService } from '../services/watchlist.service';
@Component({
selector: 'app-watchlist',
standalone: true,
imports: [RouterLink],
templateUrl: './watchlist.component.html',
styleUrl: './watchlist.component.css'
})
export class WatchlistComponent {
// Inject the shared WatchlistService
private watchlistService = inject(WatchlistService);
// Expose the computed watchlist signal to the template
watchlist = this.watchlistService.watchlist;
// Delegate removal to the service
removeFromWatchlist(movieId: number): void {
this.watchlistService.removeFromWatchlist(movieId);
}
}
What does this code do?
- inject(WatchlistService) pulls in the same singleton service instance that MovieDetailComponent uses. Because it is provided at root, both components share the same data.
- watchlist = this.watchlistService.watchlist exposes the computed signal directly. The template calls it as a function to read the current value.
- removeFromWatchlist delegates to the service so the signal updates reactively.
Why does sharing a service work?
Angular's dependency injection system creates one instance of the service for the entire app when you use providedIn: 'root'. Every component that injects it gets the same object with the same signals.
This means when MovieDetail adds a movie, the Watchlist page already sees it without any extra wiring. The signal handles change propagation automatically.
- Save watchlist.component.ts.
You will not see a change in the browser yet. The next chunk adds the template that displays watchlist data.
Build the watchlist template and styles
The template needs to handle two states: an empty watchlist with a helpful message, and a populated list showing each saved movie.
- Open src/app/watchlist/watchlist.component.html and replace its contents with the following template:
<h1>My Watchlist</h1>
@if (watchlist().length === 0) {
<div class="empty-state">
<p>Your watchlist is empty.</p>
<a routerLink="/" class="btn">Browse Movies</a>
</div>
} @else {
<div class="watchlist-grid">
@for (movie of watchlist(); track movie.id) {
<div class="watchlist-item">
<img [src]="movie.imageUrl" [alt]="movie.title">
<div class="item-info">
<h3>{{ movie.title }}</h3>
<p>{{ movie.year }} | {{ movie.genre }}</p>
<p class="rating">Rating: {{ movie.rating }}/10</p>
<div class="actions">
<a [routerLink]="['/movie', movie.id]" class="btn">View Details</a>
<button (click)="removeFromWatchlist(movie.id)" class="btn remove">Remove</button>
</div>
</div>
</div>
}
</div>
}
What does this template do?
- @if (watchlist().length === 0) checks whether the watchlist signal holds any movies. If empty, it shows a friendly message with a link back to the catalog.
- @for (movie of watchlist(); track movie.id) loops through saved movies. The track clause tells Angular how to identify each item for efficient DOM updates.
- The Remove button calls removeFromWatchlist(movie.id) which updates the signal, and Angular re-renders the list automatically.
- Save watchlist.component.html.
- Now open src/app/watchlist/watchlist.component.css and replace its contents with the following styles:
h1 {
text-align: center;
margin-bottom: 24px;
}
.empty-state {
text-align: center;
padding: 48px;
color: #666;
}
.watchlist-grid {
display: flex;
flex-direction: column;
gap: 16px;
padding: 16px;
max-width: 700px;
margin: 0 auto;
}
.watchlist-item {
display: flex;
gap: 16px;
border: 1px solid #ddd;
border-radius: 8px;
padding: 12px;
}
.watchlist-item img {
width: 80px;
height: 120px;
object-fit: cover;
border-radius: 4px;
}
.item-info h3 {
margin: 0 0 4px;
}
.rating {
font-weight: bold;
color: #f39c12;
}
.actions {
margin-top: 8px;
}
.btn {
display: inline-block;
padding: 6px 12px;
background-color: #3498db;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
text-decoration: none;
font-size: 13px;
margin-right: 8px;
}
.btn:hover {
background-color: #2980b9;
}
.btn.remove {
background-color: #e74c3c;
}
.btn.remove:hover {
background-color: #c0392b;
}
What do these styles do?
The watchlist uses a vertical column layout (flex-direction: column) unlike the catalog's grid. Each item displays horizontally with a small thumbnail on the left and movie details on the right.
The .btn.remove class applies a red background so the Remove button is visually distinct from other actions.
- Save watchlist.component.css.
The component files are complete. You still need to register the route and add a nav link before you can see it in the browser.
Seeing a compilation error?
Check that RouterLink is in the imports array of watchlist.component.ts. Without it, routerLink directives in the template will not be recognized.
help me fix a compilation error in my WatchlistComponent
Add the route and navigation link
The component exists but Angular does not know when to display it. You need a route mapping and a clickable link in the navbar.
- Open src/app/app.routes.ts and find this line near the top:
import { MovieDetailComponent } from './movie-detail/movie-detail.component';
- Add the following import directly below it:
import { WatchlistComponent } from './watchlist/watchlist.component';
- Next, find the wildcard route line:
{ path: '**', redirectTo: '' }
- Add the watchlist route directly above it, so the last two entries look like this:
{ path: 'watchlist', component: WatchlistComponent, title: 'My Watchlist' },
{ path: '**', redirectTo: '' }
Why above the wildcard?
Angular matches routes from top to bottom. The ** wildcard catches everything that did not match an earlier path. If your watchlist route came after it, Angular would never reach it.
- Save app.routes.ts.
- Now open src/app/app.component.html and find this line inside the nav-links div:
<a routerLink="/">Catalog</a>
- Add the following link directly below it:
<a routerLink="/watchlist">My Watchlist</a>
- Save app.component.html.
- Return to your browser at http://localhost:4200. You should see a My Watchlist link in the navbar.
- Click My Watchlist. You should see the empty state message "Your watchlist is empty" with a Browse Movies link.
- Navigate to any movie detail page and click Add to Watchlist.
- Click My Watchlist in the navbar again. You should see the movie you just added.
Watchlist page is blank?
Make sure the new route in app.routes.ts is placed above the { path: '**', redirectTo: '' } line. If it comes after the wildcard, Angular redirects before reaching your route.
Also check that the import path matches exactly: './watchlist/watchlist.component'.
help me fix a blank watchlist page in my Angular app
✔️ Awesome, I've got everything!
Great. Double check you have saved all three new files and the two updated files.
ⓧ I'd like to double check the full code
Here is watchlist.component.ts:
import { Component, inject } from '@angular/core';
import { RouterLink } from '@angular/router';
import { WatchlistService } from '../services/watchlist.service';
@Component({
selector: 'app-watchlist',
standalone: true,
imports: [RouterLink],
templateUrl: './watchlist.component.html',
styleUrl: './watchlist.component.css'
})
export class WatchlistComponent {
private watchlistService = inject(WatchlistService);
watchlist = this.watchlistService.watchlist;
removeFromWatchlist(movieId: number): void {
this.watchlistService.removeFromWatchlist(movieId);
}
}
Here is watchlist.component.html:
<h1>My Watchlist</h1>
@if (watchlist().length === 0) {
<div class="empty-state">
<p>Your watchlist is empty.</p>
<a routerLink="/" class="btn">Browse Movies</a>
</div>
} @else {
<div class="watchlist-grid">
@for (movie of watchlist(); track movie.id) {
<div class="watchlist-item">
<img [src]="movie.imageUrl" [alt]="movie.title">
<div class="item-info">
<h3>{{ movie.title }}</h3>
<p>{{ movie.year }} | {{ movie.genre }}</p>
<p class="rating">Rating: {{ movie.rating }}/10</p>
<div class="actions">
<a [routerLink]="['/movie', movie.id]" class="btn">View Details</a>
<button (click)="removeFromWatchlist(movie.id)" class="btn remove">Remove</button>
</div>
</div>
</div>
}
</div>
}
Here is watchlist.component.css:
h1 {
text-align: center;
margin-bottom: 24px;
}
.empty-state {
text-align: center;
padding: 48px;
color: #666;
}
.watchlist-grid {
display: flex;
flex-direction: column;
gap: 16px;
padding: 16px;
max-width: 700px;
margin: 0 auto;
}
.watchlist-item {
display: flex;
gap: 16px;
border: 1px solid #ddd;
border-radius: 8px;
padding: 12px;
}
.watchlist-item img {
width: 80px;
height: 120px;
object-fit: cover;
border-radius: 4px;
}
.item-info h3 {
margin: 0 0 4px;
}
.rating {
font-weight: bold;
color: #f39c12;
}
.actions {
margin-top: 8px;
}
.btn {
display: inline-block;
padding: 6px 12px;
background-color: #3498db;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
text-decoration: none;
font-size: 13px;
margin-right: 8px;
}
.btn:hover {
background-color: #2980b9;
}
.btn.remove {
background-color: #e74c3c;
}
.btn.remove:hover {
background-color: #c0392b;
}
Here is the updated app.routes.ts:
import { Routes } from '@angular/router';
import { MovieCatalogComponent } from './movie-catalog/movie-catalog.component';
import { MovieDetailComponent } from './movie-detail/movie-detail.component';
import { WatchlistComponent } from './watchlist/watchlist.component';
export const routes: Routes = [
{ path: '', component: MovieCatalogComponent, title: 'Movie Catalog' },
{ path: 'movie/:id', component: MovieDetailComponent, title: 'Movie Details' },
{ path: 'watchlist', component: WatchlistComponent, title: 'My Watchlist' },
{ path: '**', redirectTo: '' }
];
Here is the updated app.component.html:
<nav class="navbar">
<a routerLink="/" class="brand">Movie Watchlist</a>
<div class="nav-links">
<a routerLink="/">Catalog</a>
<a routerLink="/watchlist">My Watchlist</a>
</div>
</nav>
<main>
<router-outlet />
</main>
Your app now has a fully functional watchlist. Users can browse movies, save favorites, and manage their list from a dedicated page. The shared service keeps everything in sync as they navigate between pages.
Secret mission
Add Search and Filter
Your movie catalog works, but finding a specific movie means scrolling through the entire grid. In this secret mission, you'll add a real-time search bar and genre filter buttons that narrow results instantly as the user types or clicks.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- The Angular dev server running at http://localhost:4200.
- The movie-watchlist project folder on your Desktop.
- Angular CLI installed globally via npm.
Keep everything running
No action needed. Choose this if you are still actively building or want to experiment with the app further.
- Your dev server stays running at http://localhost:4200.
- Angular CLI remains available globally for new projects.
- Your movie-watchlist folder stays on your Desktop.
Pause - I'll come back to this later
Shut down the running dev server to free up memory and your terminal. Your project files stay intact for when you return.
- Switch to the terminal where npm start is running.
- Press Ctrl+C to stop the dev server.
- Your project files remain in the movie-watchlist folder. Run npm start from inside that folder whenever you want to resume.
Delete - I don't want to use this again
Remove all project resources and start fresh. This permanently deletes the project files and uninstalls Angular CLI.
- Switch to the terminal where npm start is running and press Ctrl+C to stop the dev server.
- Delete the project folder by running these commands:
macOS
cd ~/Desktop
rm -rf movie-watchlist
Windows
cd ~/Desktop
rmdir /s /q movie-watchlist
- Uninstall Angular CLI globally by running:
npm uninstall -g @angular/cli
- Verify it was removed by running:
ng version
You should see a "command not found" message, confirming Angular CLI has been removed.
Nice Work!
Nice Work!
You just built a full multi-page Movie Watchlist application using Angular v22. You now have hands-on experience with the core building blocks that professional Angular teams use every day.
Here's what you learned:
- Built a multi-page Angular v22 application using standalone components and Signals for reactive state management.
- Implemented Angular Router with route parameters to navigate between a movie catalog, detail pages, and a personal watchlist.
- Created a shared WatchlistService using dependency injection to manage watchlist state across multiple components.
- Secret Mission: Added real-time search and genre filtering using computed signals that derive filtered results from multiple signal inputs.
Ready to quiz yourself?