Build a Bookmark Manager with Angular
Build a reactive Angular SPA with signals, routing, and forms.
Introduction
30 Second Summary
Everyone has their go-to collection of websites they visit daily for work, learning, or fun. Managing those bookmarks across browsers can feel messy without a dedicated tool.
In this project, you will build a Bookmark Manager single-page application using Angular v22. You'll learn Angular's core modern patterns including components, signals, routing, and template-driven forms.
What You'll Build
A Bookmark Manager SPA where users can add bookmarks with a title, URL, and category, view all bookmarks in a categorized list, and filter them by category.
By the end of this project, you'll have:
- A fully functional Angular app with two routed views for listing and adding bookmarks.
- A shared bookmark service using Signals for reactive state management.
- An SCSS-styled UI with category filtering and reusable components.
- Secret Mission: Add an "Edit Bookmark" feature with a pre-filled form using route parameters.
Are there any prerequisites?
This project assumes you have solid JavaScript experience, basic TypeScript familiarity, and working knowledge of HTML/CSS/SCSS. No prior Angular experience is required.
Set Up Your Angular Project
Every Angular application starts with a project scaffold. The Angular CLI handles this for you, generating a complete project structure with configuration, routing, and a development server ready to go.
In this step, you will install the Angular CLI, scaffold a new project, verify it runs in the browser, and then replace the default template with your own custom navbar layout.
In this step, get ready to:
- Install the Angular CLI and create the project.
- Run the development server and verify it works.
- Clean up the scaffold and set up your own template.
Install the Angular CLI and create the project
The Angular CLI is a command-line tool that scaffolds, builds, and serves Angular applications. You need it installed globally before you can create a project.
- Check whether the Angular CLI is already installed by running this command:
ng version
✔️ I see version 22 or higher
The Angular CLI is ready to go. Skip ahead to creating the project below.
ⓧ I see an older version
You have an outdated version of the Angular CLI. Update it to the latest version by running:
npm install -g @angular/cli
Once the install finishes, run ng version again to confirm you see version 22 or higher.
ⓧ Command not found
The Angular CLI is not installed yet. Install it globally by running:
npm install -g @angular/cli
Once the install finishes, run ng version again to confirm you see version 22 or higher.
What is the Angular CLI?
The Angular CLI (ng) is published as @angular/cli on npm. It provides commands to generate components, services, and other building blocks, as well as build and serve your application.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your Angular project with SCSS styling by running this command:
ng new bookmark-manager --style=scss
What do these options mean?
The --style=scss flag tells Angular to use SCSS for stylesheets instead of plain CSS. SCSS gives you nesting, variables, and other features you are already familiar with.
The CLI will ask a few interactive prompts about optional features. Press Enter to accept the defaults for each prompt. Routing is included by default.
- Once the scaffold finishes, navigate into the project folder by running:
cd bookmark-manager
Seeing permission errors?
On macOS, you may need to prefix the install command with sudo (e.g. sudo npm install -g @angular/cli). On Windows, try running your terminal as Administrator.
Help me fix Angular CLI permission errors
Run the development server and verify
Before making any changes, confirm the scaffold works out of the box.
- Start the development server by running:
npm start
- Open Google Chrome and navigate to http://localhost:4200.
You should see the default Angular welcome page with the Angular logo and links to documentation.
What does npm start do?
Running npm start launches the Angular development server. It watches your files for changes and automatically rebuilds the app, so you can see updates in the browser without manually refreshing.
Don't see the welcome page?
Make sure your terminal is inside the bookmark-manager folder before running npm start. If port 4200 is already in use, the terminal will show an error. Close any other dev servers you have running.
Help me get the Angular dev server running
Clean up the scaffold and understand the file structure
The default template is just a placeholder. You will replace it with a navbar and a router outlet that will display your bookmark views.
- Switch to VS Code. If the bookmark-manager folder is not already open, click File in the top menu bar.
- Click Open Folder.
- Select the bookmark-manager folder on your Desktop, then click Open.
What are these key files?
Angular v22 uses a concise file naming style. Here are the files you will work with:
- src/app/app.ts is the root component. It controls what appears on every page of your app.
- src/app/app.routes.ts defines URL paths and which component renders for each path.
- src/app/app.config.ts provides application-level configuration like the router.
First, replace the global styles. These apply to the entire page regardless of which component is active.
- In the VS Code Explorer sidebar, open src/styles.scss.
- Replace all the contents of the file with this code:
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #f5f7fa;
min-height: 100vh;
}
What does this code do?
The universal selector * resets all default margins and padding so your layout starts clean. The box-sizing: border-box rule makes width calculations predictable by including padding in the element's total size.
The body rule sets a system font stack and a soft gray background that will sit behind your bookmark cards.
- Save src/styles.scss.
Next, set up the root component template with your navbar layout.
- In the Explorer sidebar, expand src/app/ and open app.html.
- Delete everything in the file and replace it with this code:
<nav class="navbar">
<h1 class="navbar-brand">Bookmark Manager</h1>
<div class="navbar-links">
<a routerLink="/" routerLinkActive="active" [routerLinkActiveOptions]="{ exact: true }">All Bookmarks</a>
<a routerLink="/add" routerLinkActive="active">+ New Bookmark</a>
</div>
<span class="bookmark-count">{{ bookmarkCount().length }} bookmarks</span>
</nav>
<main class="content">
<router-outlet />
</main>
What does this template do?
The routerLink directive tells Angular which route to navigate to when the link is clicked. The routerLinkActive directive automatically adds the active CSS class to the link that matches the current URL.
The <router-outlet /> is a placeholder where Angular renders the component that matches the current route. Each view you build later will appear inside this outlet.
The {{ bookmarkCount().length }} expression reads from a signal that you will create in the next step. For now, this will cause a compilation error, which is expected.
- Save src/app/app.html.
Now update the root component TypeScript file to import the router directives and prepare for the bookmark service.
- Open src/app/app.ts.
- Replace all the contents of the file with this code:
import { Component, inject } from '@angular/core';
import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
import { BookmarkService } from './bookmark.service';
@Component({
selector: 'app-root',
imports: [RouterOutlet, RouterLink, RouterLinkActive],
templateUrl: './app.html',
styleUrl: './app.scss',
})
export class App {
private bookmarkService = inject(BookmarkService);
bookmarkCount = this.bookmarkService.allBookmarks;
}
What does this code do?
The imports array in the @Component decorator tells Angular which directives and components this component uses in its template. You are importing RouterOutlet, RouterLink, and RouterLinkActive so the navbar links and router outlet work.
The inject(BookmarkService) line uses Angular's modern dependency injection pattern. The BookmarkService does not exist yet, so you will see a compilation error until you create it in the next step.
- Save src/app/app.ts.
Finally, add the SCSS styles for the navbar and content layout.
- Open src/app/app.scss.
- Replace all the contents of the file with this code:
:host {
display: block;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
color: #1a1a2e;
}
.navbar {
display: flex;
align-items: center;
gap: 1.5rem;
padding: 1rem 2rem;
background: #16213e;
color: #fff;
.navbar-brand {
font-size: 1.25rem;
margin: 0;
font-weight: 600;
}
.navbar-links {
display: flex;
gap: 1rem;
a {
color: #a8b2d1;
text-decoration: none;
padding: 0.5rem 1rem;
border-radius: 6px;
transition: background 0.2s;
&:hover,
&.active {
background: #0f3460;
color: #fff;
}
}
}
.bookmark-count {
margin-left: auto;
font-size: 0.875rem;
color: #a8b2d1;
}
}
.content {
max-width: 900px;
margin: 2rem auto;
padding: 0 1rem;
}
What does this code do?
The :host selector targets the component's own element. This is how you style the wrapper of a component in Angular's scoped SCSS.
The navbar uses flexbox to align the brand, links, and bookmark count in a row. The margin-left: auto on .bookmark-count pushes it to the far right of the navbar.
- Save src/app/app.scss.
Seeing a compilation error? That is expected
Your terminal will show an error because app.ts imports BookmarkService from ./bookmark.service, which does not exist yet. You will create this service in the next step, and the error will disappear.
✔️ Awesome, I've got everything!
Great. Double check you have saved all four files: src/styles.scss, src/app/app.html, src/app/app.ts, and src/app/app.scss.
ⓧ I'd like to double check the full code
Here is the complete code for each file at this point. Compare your files against these.
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #f5f7fa;
min-height: 100vh;
}
<nav class="navbar">
<h1 class="navbar-brand">Bookmark Manager</h1>
<div class="navbar-links">
<a routerLink="/" routerLinkActive="active" [routerLinkActiveOptions]="{ exact: true }">All Bookmarks</a>
<a routerLink="/add" routerLinkActive="active">+ New Bookmark</a>
</div>
<span class="bookmark-count">{{ bookmarkCount().length }} bookmarks</span>
</nav>
<main class="content">
<router-outlet />
</main>
import { Component, inject } from '@angular/core';
import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
import { BookmarkService } from './bookmark.service';
@Component({
selector: 'app-root',
imports: [RouterOutlet, RouterLink, RouterLinkActive],
templateUrl: './app.html',
styleUrl: './app.scss',
})
export class App {
private bookmarkService = inject(BookmarkService);
bookmarkCount = this.bookmarkService.allBookmarks;
}
:host {
display: block;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
color: #1a1a2e;
}
.navbar {
display: flex;
align-items: center;
gap: 1.5rem;
padding: 1rem 2rem;
background: #16213e;
color: #fff;
.navbar-brand {
font-size: 1.25rem;
margin: 0;
font-weight: 600;
}
.navbar-links {
display: flex;
gap: 1rem;
a {
color: #a8b2d1;
text-decoration: none;
padding: 0.5rem 1rem;
border-radius: 6px;
transition: background 0.2s;
&:hover,
&.active {
background: #0f3460;
color: #fff;
}
}
}
.bookmark-count {
margin-left: auto;
font-size: 0.875rem;
color: #a8b2d1;
}
}
.content {
max-width: 900px;
margin: 2rem auto;
padding: 0 1rem;
}
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 = [];
Your project structure is in place. In the next step, you will create the BookmarkService that powers the bookmark count in the navbar and holds all your bookmark data using Angular Signals.
Create the Bookmark Service with Signals
Your Angular app has a shell with navigation, but no data to display yet. Right now, the root component references a BookmarkService that does not exist, so the dev server shows a compile error.
In this step, you will create a central service that holds your bookmark data using Signals. Signals are Angular's reactive primitive for state management. When signal values change, any template that reads them updates automatically.
In this step, get ready to:
- Define a Bookmark data model and generate a service with the Angular CLI.
- Add methods to create, read, and derive data from the bookmark signal.
- Verify the service connects to the root component and displays a live bookmark count.
Generate the bookmark service and define the data model
Before writing the service itself, you need a TypeScript interface that describes the shape of a bookmark. This keeps your data typed and predictable throughout the app.
- Create a new file called bookmark.ts inside src/app/ and add the following interface:
export interface Bookmark {
id: string;
title: string;
url: string;
category: string;
}
What does this interface do?
The Bookmark interface defines the four fields every bookmark must have. TypeScript uses this to catch typos and missing fields at compile time, before your app ever runs.
- Save bookmark.ts.
- Confirm the file exists in your VS Code Explorer sidebar under src/app/.
Now generate the service file using the Angular CLI.
- Open a new terminal in VS Code (the dev server is using the first one) and run this command:
ng generate service bookmark --type=service
What does this command do?
The ng generate service command scaffolds a new injectable service file. The --type=service flag ensures the file is named bookmark.service.ts with the conventional suffix.
Angular services are singletons by default. Every component that injects the service shares the same instance and the same data.
- You should see output confirming two files were created: bookmark.service.ts and bookmark.service.spec.ts.
Command not found?
Make sure you are running the command from inside the bookmark-manager folder. If you see a permission error on macOS, try prefixing with npx: npx ng generate service bookmark --type=service.
help me fix the ng generate command
Next, replace the generated placeholder code with your signal-based bookmark store.
- Open src/app/bookmark.service.ts and replace its entire contents with:
import { Injectable, signal, computed } from '@angular/core';
import { Bookmark } from './bookmark';
@Injectable({
providedIn: 'root',
})
export class BookmarkService {
// Writable signal holding all bookmarks
private bookmarks = signal<Bookmark[]>([
{ id: '1', title: 'Angular Docs', url: 'https://angular.dev', category: 'Development' },
{ id: '2', title: 'TypeScript Handbook', url: 'https://www.typescriptlang.org/docs/', category: 'Development' },
{ id: '3', title: 'MDN Web Docs', url: 'https://developer.mozilla.org', category: 'Reference' },
]);
}
What does this code do?
- signal<Bookmark[]>([...]) creates a writable signal holding an array of bookmarks. Signals are reactive containers. Any template or computed signal that reads this value will automatically re-render when the array changes.
- The private keyword means only code inside BookmarkService can write to this signal. External components get read-only access.
- The three sample bookmarks give you data to see immediately in the UI.
- Save bookmark.service.ts.
- Check your terminal running the dev server. The compile error should disappear now that the service file exists.
Still seeing a compile error?
Make sure you saved both bookmark.ts and bookmark.service.ts. The service imports the Bookmark interface, so both files must exist.
help me fix this Angular compile error
Add methods to manage bookmarks
The signal holds data, but components need a way to read it safely and add new bookmarks. You will expose a read-only version of the signal and add a computed signal that derives unique categories.
- In bookmark.service.ts, add the following code inside the BookmarkService class, below the bookmarks signal:
// Expose bookmarks as read-only so components cannot overwrite the array
readonly allBookmarks = this.bookmarks.asReadonly();
// Derive unique category names from the current bookmarks
readonly categories = computed(() => {
const cats = this.bookmarks().map((b) => b.category);
return [...new Set(cats)];
});
What is happening here?
- asReadonly() wraps the writable signal in a read-only shell. Components can read the value but cannot call .set() or .update() on it.
- computed() creates a derived signal that recalculates automatically whenever the source signal changes. Here it extracts unique category strings using a Set.
Now add the methods that let components create and look up bookmarks.
- Still inside the BookmarkService class, add these methods below the categories computed signal:
// Add a new bookmark with a generated unique ID
addBookmark(bookmark: Omit<Bookmark, 'id'>) {
const newBookmark: Bookmark = {
...bookmark,
id: crypto.randomUUID(),
};
this.bookmarks.update((current) => [...current, newBookmark]);
}
// Find a single bookmark by its ID
getBookmarkById(id: string) {
return this.bookmarks().find((b) => b.id === id);
}
What do these methods do?
- addBookmark() accepts a bookmark without an id field (using TypeScript's Omit utility type). It generates a unique ID with crypto.randomUUID(), then uses .update() to immutably add the new bookmark to the array.
- getBookmarkById() reads the current signal value and finds a bookmark by its ID. You will use this later for the edit feature.
- Save bookmark.service.ts.
- Your dev server terminal should show a successful compilation with no errors.
Seeing a type error on Omit or computed?
Check that your import statement at the top of the file includes signal and computed from @angular/core. Also confirm you imported Bookmark from ./bookmark.
help me fix type errors in my BookmarkService
✔️ Awesome, I've got everything!
Great. Double check you have saved bookmark.service.ts and your dev server shows no errors.
ⓧ I'd like to double check the full code
Here is the complete bookmark.service.ts file for comparison:
import { Injectable, signal, computed } from '@angular/core';
import { Bookmark } from './bookmark';
@Injectable({
providedIn: 'root',
})
export class BookmarkService {
private bookmarks = signal<Bookmark[]>([
{ id: '1', title: 'Angular Docs', url: 'https://angular.dev', category: 'Development' },
{ id: '2', title: 'TypeScript Handbook', url: 'https://www.typescriptlang.org/docs/', category: 'Development' },
{ id: '3', title: 'MDN Web Docs', url: 'https://developer.mozilla.org', category: 'Reference' },
]);
readonly allBookmarks = this.bookmarks.asReadonly();
readonly categories = computed(() => {
const cats = this.bookmarks().map((b) => b.category);
return [...new Set(cats)];
});
addBookmark(bookmark: Omit<Bookmark, 'id'>) {
const newBookmark: Bookmark = {
...bookmark,
id: crypto.randomUUID(),
};
this.bookmarks.update((current) => [...current, newBookmark]);
}
getBookmarkById(id: string) {
return this.bookmarks().find((b) => b.id === id);
}
}
Inject and test the service
Your root component already imports and injects BookmarkService from the previous step. Now that the service file exists, Angular can resolve the dependency and compile the app.
- Switch back to your browser at http://localhost:4200.
- Look at the right side of the navbar. You should see 3 bookmarks displayed.
How does the count get there?
In app.ts, the line bookmarkCount = this.bookmarkService.allBookmarks stores a reference to the read-only signal. In the template, {{ bookmarkCount().length }} calls the signal (like a function) to get the current array, then reads its length.
When you add or remove bookmarks later, this count will update automatically because signals are reactive.
Don't see the count in the navbar?
Make sure all three files are saved: bookmark.ts, bookmark.service.ts, and app.ts. If the dev server stopped, restart it by running npm start in the terminal.
help me debug why the bookmark count is not showing
Your bookmark data layer is complete. Next up, you will build a list component that reads these signals and displays each bookmark as a filterable card.
Build the Bookmark List View
Your bookmark service is ready with sample data and reactive signals. Now it's time to build the view that actually displays those bookmarks to the user.
In this step, you'll create a dedicated component that reads from the service, renders each bookmark as a card, and lets users filter by category. This is where Angular's Signals and built-in control flow come together to produce a responsive UI.
In this step, get ready to:
- Generate the bookmark-list component and wire up the service.
- Add category filtering with signals and computed values.
- Connect the route and style the bookmark cards.
Generate the bookmark-list component and display bookmarks
Angular organizes UI into components. Each component owns a piece of the screen and has its own TypeScript logic, HTML template, and styles.
- Generate a new component by running this command in your terminal:
ng generate component bookmark-list
What does this command do?
The Angular CLI creates a bookmark-list folder inside src/app/ with three files: bookmark-list.ts (component logic), bookmark-list.html (template), and bookmark-list.scss (styles). Angular v22 uses the 2025 naming convention, so there is no .component suffix in the filename or class name.
- Confirm the files were created by checking for the bookmark-list folder in the Explorer sidebar of VS Code.
Don't see the folder?
Make sure your terminal is inside the bookmark-manager folder when you run the command. If you're in the parent directory, the CLI won't find the Angular workspace.
Why isn't ng generate creating my component files?
Now you'll write the component's TypeScript logic. This is where you inject the service and set up the signals that drive the view.
- Open src/app/bookmark-list/bookmark-list.ts and replace its contents with the following code:
import { Component, inject, signal, computed } from '@angular/core';
import { BookmarkService } from '../bookmark.service';
@Component({
selector: 'app-bookmark-list',
templateUrl: './bookmark-list.html',
styleUrl: './bookmark-list.scss',
})
export class BookmarkList {
private bookmarkService = inject(BookmarkService);
// Read the bookmarks and categories signals from the service
allBookmarks = this.bookmarkService.allBookmarks;
categories = this.bookmarkService.categories;
// Track which category filter is active
selectedCategory = signal('all');
// Derive a filtered list based on the selected category
filteredBookmarks = computed(() => {
const category = this.selectedCategory();
const bookmarks = this.allBookmarks();
if (category === 'all') {
return bookmarks;
}
return bookmarks.filter((b) => b.category === category);
});
selectCategory(category: string) {
this.selectedCategory.set(category);
}
}
What does this code do?
- inject(BookmarkService) pulls the shared service into this component without constructor injection.
- allBookmarks and categories are read-only references to the service's signals. When bookmarks change, these update automatically.
- selectedCategory is a local writable signal that tracks which filter button the user clicked.
- filteredBookmarks is a computed() signal. Angular recalculates it whenever selectedCategory or allBookmarks changes.
- Save bookmark-list.ts.
You won't see a visual change yet. The next substep adds the HTML template that reads these signals.
Add category filtering
Angular's built-in control flow lets you loop and conditionally render elements directly in your template. You'll use @for to iterate over bookmarks and categories, and @empty to show a fallback when no results match.
- Open src/app/bookmark-list/bookmark-list.html and replace its contents with this template:
<div class="filters">
<button
[class.active]="selectedCategory() === 'all'"
(click)="selectCategory('all')"
>
All
</button>
@for (category of categories(); track category) {
<button
[class.active]="selectedCategory() === category"
(click)="selectCategory(category)"
>
{{ category }}
</button>
}
</div>
<div class="bookmark-grid">
@for (bookmark of filteredBookmarks(); track bookmark.id) {
<div class="bookmark-card">
<h3 class="bookmark-title">{{ bookmark.title }}</h3>
<a [href]="bookmark.url" target="_blank" rel="noopener" class="bookmark-url">
{{ bookmark.url }}
</a>
<span class="bookmark-category">{{ bookmark.category }}</span>
</div>
} @empty {
<p class="empty-state">No bookmarks found in this category.</p>
}
</div>
What does this template do?
- The filters section renders one button per category (plus an "All" button). Clicking a button calls selectCategory() which updates the signal.
- @for (bookmark of filteredBookmarks(); track bookmark.id) loops over the computed signal's current value. The track clause tells Angular how to identify each item for efficient DOM updates.
- @empty renders only when the @for loop has zero items. This gives users clear feedback when a category has no bookmarks.
- [class.active] conditionally adds the active CSS class when the button's category matches the selected one.
- Save bookmark-list.html.
The template is ready, but you won't see it in the browser until you connect the route in the next substep.
Seeing a template parse error?
Check that your @for blocks include the track clause. Angular v22 requires it for every @for loop. Make sure each signal is called with parentheses (e.g. filteredBookmarks() not filteredBookmarks).
Help me debug my Angular template error.
Wire up the route and style the view
The Angular Router decides which component to show based on the browser's URL. You need to register your new component as the default route so it appears when users visit the app.
- Open src/app/app.routes.ts and replace its contents with the following:
import { Routes } from '@angular/router';
import { BookmarkList } from './bookmark-list/bookmark-list';
import { AddBookmark } from './add-bookmark/add-bookmark';
export const routes: Routes = [
{ path: '', component: BookmarkList, title: 'My Bookmarks' },
{ path: 'add', component: AddBookmark, title: 'Add Bookmark' },
];
What does this code do?
The path: '' entry makes BookmarkList the default view when users visit the root URL. The title property sets the browser tab title for that route.
The add route references the AddBookmark component you'll build in the next step. Angular will show a compilation error for that import until the component exists.
- Save app.routes.ts.
Now let's add the styles that turn the raw HTML into proper bookmark cards with rounded filter buttons.
- Open src/app/bookmark-list/bookmark-list.scss and replace its contents with the following styles:
.filters {
display: flex;
gap: 0.5rem;
margin-bottom: 1.5rem;
flex-wrap: wrap;
button {
padding: 0.5rem 1rem;
border: 1px solid #e0e0e0;
border-radius: 20px;
background: #fff;
cursor: pointer;
font-size: 0.875rem;
transition: all 0.2s;
&:hover {
border-color: #0f3460;
color: #0f3460;
}
&.active {
background: #0f3460;
color: #fff;
border-color: #0f3460;
}
}
}
.bookmark-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1rem;
}
.bookmark-card {
padding: 1.25rem;
border: 1px solid #e0e0e0;
border-radius: 8px;
background: #fff;
transition: box-shadow 0.2s;
&:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.bookmark-title {
margin: 0 0 0.5rem;
font-size: 1rem;
font-weight: 600;
}
.bookmark-url {
display: block;
color: #0f3460;
font-size: 0.8125rem;
text-decoration: none;
margin-bottom: 0.75rem;
word-break: break-all;
&:hover {
text-decoration: underline;
}
}
.bookmark-category {
display: inline-block;
padding: 0.25rem 0.75rem;
background: #e8f0fe;
color: #0f3460;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 500;
}
}
.empty-state {
grid-column: 1 / -1;
text-align: center;
color: #666;
padding: 2rem;
}
What do these styles do?
- .filters uses flexbox with pill-shaped buttons. The .active class fills the selected button with a dark background.
- .bookmark-grid uses CSS Grid with auto-fill so cards wrap responsively across screen sizes.
- .bookmark-card adds a subtle hover shadow to give cards depth when the user interacts with them.
- Save bookmark-list.scss and check your browser at http://localhost:4200.
- You should see three bookmark cards in a grid layout with "All", "Development", and "Reference" filter buttons above them.
- Click the Reference button. Only the MDN Web Docs card should remain visible.
Don't see any bookmark cards?
If the page is blank, check that app.routes.ts imports BookmarkList from the correct path ('./bookmark-list/bookmark-list'). Also confirm your app.html has <router-outlet /> inside the <main> tag.
If you see a compilation error about AddBookmark, that's expected until you build that component in the next step. The bookmark list still renders if the default route is correct.
Help me debug why my bookmark list isn't rendering.
✔️ Awesome, I've got everything!
Great. Double check you've saved all three files in the bookmark-list folder.
ⓧ I'd like to double check the full code
Here's the full code for each file you created or edited in this step.
import { Component, inject, signal, computed } from '@angular/core';
import { BookmarkService } from '../bookmark.service';
@Component({
selector: 'app-bookmark-list',
templateUrl: './bookmark-list.html',
styleUrl: './bookmark-list.scss',
})
export class BookmarkList {
private bookmarkService = inject(BookmarkService);
allBookmarks = this.bookmarkService.allBookmarks;
categories = this.bookmarkService.categories;
selectedCategory = signal('all');
filteredBookmarks = computed(() => {
const category = this.selectedCategory();
const bookmarks = this.allBookmarks();
if (category === 'all') {
return bookmarks;
}
return bookmarks.filter((b) => b.category === category);
});
selectCategory(category: string) {
this.selectedCategory.set(category);
}
}
<div class="filters">
<button
[class.active]="selectedCategory() === 'all'"
(click)="selectCategory('all')"
>
All
</button>
@for (category of categories(); track category) {
<button
[class.active]="selectedCategory() === category"
(click)="selectCategory(category)"
>
{{ category }}
</button>
}
</div>
<div class="bookmark-grid">
@for (bookmark of filteredBookmarks(); track bookmark.id) {
<div class="bookmark-card">
<h3 class="bookmark-title">{{ bookmark.title }}</h3>
<a [href]="bookmark.url" target="_blank" rel="noopener" class="bookmark-url">
{{ bookmark.url }}
</a>
<span class="bookmark-category">{{ bookmark.category }}</span>
</div>
} @empty {
<p class="empty-state">No bookmarks found in this category.</p>
}
</div>
.filters {
display: flex;
gap: 0.5rem;
margin-bottom: 1.5rem;
flex-wrap: wrap;
button {
padding: 0.5rem 1rem;
border: 1px solid #e0e0e0;
border-radius: 20px;
background: #fff;
cursor: pointer;
font-size: 0.875rem;
transition: all 0.2s;
&:hover {
border-color: #0f3460;
color: #0f3460;
}
&.active {
background: #0f3460;
color: #fff;
border-color: #0f3460;
}
}
}
.bookmark-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1rem;
}
.bookmark-card {
padding: 1.25rem;
border: 1px solid #e0e0e0;
border-radius: 8px;
background: #fff;
transition: box-shadow 0.2s;
&:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.bookmark-title {
margin: 0 0 0.5rem;
font-size: 1rem;
font-weight: 600;
}
.bookmark-url {
display: block;
color: #0f3460;
font-size: 0.8125rem;
text-decoration: none;
margin-bottom: 0.75rem;
word-break: break-all;
&:hover {
text-decoration: underline;
}
}
.bookmark-category {
display: inline-block;
padding: 0.25rem 0.75rem;
background: #e8f0fe;
color: #0f3460;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 500;
}
}
.empty-state {
grid-column: 1 / -1;
text-align: center;
color: #666;
padding: 2rem;
}
import { Routes } from '@angular/router';
import { BookmarkList } from './bookmark-list/bookmark-list';
import { AddBookmark } from './add-bookmark/add-bookmark';
export const routes: Routes = [
{ path: '', component: BookmarkList, title: 'My Bookmarks' },
{ path: 'add', component: AddBookmark, title: 'Add Bookmark' },
];
Your bookmark list is live with filtering powered entirely by signals. Next up, you'll build the form that lets users add new bookmarks to the collection.
Build the Add Bookmark Form
Your bookmark list displays data from the service, but users have no way to add their own bookmarks yet.
In this step, you'll build a form component that collects a title, URL, and category from the user. Angular's template-driven forms handle two-way data binding and validation with minimal code.
In this step, get ready to:
- Generate the add-bookmark component and configure it with FormsModule.
- Build a template-driven form with two-way binding and validation.
- Style the form and verify the full add-bookmark workflow.
Generate the add-bookmark component with a template-driven form
Template-driven forms use directives in the HTML template to handle binding and validation. The FormsModule package provides these directives, including ngModel for two-way binding.
- Generate the add-bookmark component by running this command in your terminal:
ng generate component add-bookmark
What does this command do?
The Angular CLI creates three files inside src/app/add-bookmark/: add-bookmark.ts (the component class), add-bookmark.html (the template), and add-bookmark.scss (the styles).
With Angular v22's 2025 naming style, there is no .component suffix in filenames or class names.
- Confirm the files were created by running:
ls src/app/add-bookmark/
You should see add-bookmark.html, add-bookmark.scss, and add-bookmark.ts in the output.
- In VS Code, open src/app/add-bookmark/add-bookmark.ts.
- Replace the generated content with the following code:
import { Component, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { BookmarkService } from '../bookmark.service';
@Component({
selector: 'app-add-bookmark',
imports: [FormsModule],
templateUrl: './add-bookmark.html',
styleUrl: './add-bookmark.scss',
})
export class AddBookmark {
private bookmarkService = inject(BookmarkService);
private router = inject(Router);
// Expose categories from the service for the dropdown
categories = this.bookmarkService.categories;
// Local object to hold form field values
bookmark = {
title: '',
url: '',
category: '',
};
// Support adding a brand-new category
newCategory = '';
showNewCategory = false;
onSubmit() {
// Use the new category if toggled, otherwise use the dropdown value
const category = this.showNewCategory ? this.newCategory : this.bookmark.category;
this.bookmarkService.addBookmark({
title: this.bookmark.title,
url: this.bookmark.url,
category: category,
});
// Navigate back to the bookmark list
this.router.navigate(['/']);
}
}
What does this code do?
- FormsModule is added to the imports array so Angular's form directives (like ngModel) are available in this component's template.
- inject(BookmarkService) gives access to the shared bookmark state. inject(Router) enables programmatic navigation after submission.
- The bookmark object holds the form values that ngModel will bind to.
- onSubmit() reads either the existing category or a new one the user typed, calls addBookmark() on the service, and navigates home.
- Save add-bookmark.ts.
Seeing a compile error?
Make sure FormsModule is imported from '@angular/forms' and listed in the imports array of the @Component decorator. Missing it is the most common cause of ngModel errors.
Also check that the import path for BookmarkService uses '../bookmark.service' (one level up from the component folder).
Help me debug my add-bookmark compile error
Build the form template with validation
Angular's template-driven forms let you declare validation rules directly in your HTML. The [(ngModel)] syntax creates two-way binding between each input and the component's bookmark object.
- Open src/app/add-bookmark/add-bookmark.html and replace its contents with:
<h2>Add New Bookmark</h2>
<form #bookmarkForm="ngForm" (ngSubmit)="onSubmit()">
<div class="form-group">
<label for="title">Title</label>
<input
type="text"
id="title"
name="title"
[(ngModel)]="bookmark.title"
required
placeholder="e.g. Angular Documentation"
/>
</div>
<div class="form-group">
<label for="url">URL</label>
<input
type="url"
id="url"
name="url"
[(ngModel)]="bookmark.url"
required
placeholder="https://example.com"
/>
</div>
<div class="form-group">
<label for="category">Category</label>
@if (!showNewCategory) {
<select id="category" name="category" [(ngModel)]="bookmark.category" required>
<option value="" disabled>Select a category</option>
@for (cat of categories(); track cat) {
<option [value]="cat">{{ cat }}</option>
}
</select>
<button type="button" class="link-button" (click)="showNewCategory = true">
+ New category
</button>
} @else {
<input
type="text"
id="newCategory"
name="newCategory"
[(ngModel)]="newCategory"
required
placeholder="Enter new category"
/>
<button type="button" class="link-button" (click)="showNewCategory = false">
Use existing
</button>
}
</div>
<button type="submit" class="submit-button" [disabled]="!bookmarkForm.form.valid">
Save Bookmark
</button>
</form>
What does this template do?
- #bookmarkForm="ngForm" creates a template reference variable that exposes the form's validity state.
- (ngSubmit)="onSubmit()" fires the component's submit handler when the user submits the form.
- Each [(ngModel)] directive binds an input's value to a property on the bookmark object. The name attribute is required for ngModel to register the control.
- The required attribute on inputs means the form stays invalid until those fields have values.
- [disabled]="!bookmarkForm.form.valid" grays out the submit button until all required fields pass validation.
- The @if/@else block toggles between a category dropdown and a text input for creating a new category.
- Save add-bookmark.html.
Form fields not binding?
Every input using [(ngModel)] must also have a name attribute. Without it, Angular cannot register the form control and you will see a console error.
Help me fix my ngModel binding
Style the form and test the workflow
The form needs styling to match the rest of the app. Component-scoped SCSS keeps these styles isolated to the add-bookmark view.
- Open src/app/add-bookmark/add-bookmark.scss and replace its contents with:
h2 {
margin: 0 0 1.5rem;
font-weight: 600;
}
form {
max-width: 500px;
}
.form-group {
margin-bottom: 1.25rem;
label {
display: block;
margin-bottom: 0.375rem;
font-size: 0.875rem;
font-weight: 500;
color: #333;
}
input,
select {
width: 100%;
padding: 0.625rem 0.75rem;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 0.9375rem;
transition: border-color 0.2s;
box-sizing: border-box;
&:focus {
outline: none;
border-color: #0f3460;
}
}
.link-button {
display: inline-block;
margin-top: 0.5rem;
background: none;
border: none;
color: #0f3460;
font-size: 0.8125rem;
cursor: pointer;
padding: 0;
&:hover {
text-decoration: underline;
}
}
}
.submit-button {
padding: 0.75rem 1.5rem;
background: #0f3460;
color: #fff;
border: none;
border-radius: 6px;
font-size: 1rem;
cursor: pointer;
transition: background 0.2s;
&:hover:not(:disabled) {
background: #16213e;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
What does this stylesheet do?
- The .form-group class adds spacing and styles labels, inputs, and the select dropdown consistently.
- The .link-button class makes the "+ New category" toggle look like a subtle text link instead of a standard button.
- The .submit-button uses &:disabled to dim the button when the form is invalid, giving the user a clear visual cue.
- Save add-bookmark.scss.
- In your browser, click + New Bookmark in the navbar.
- You should see the "Add New Bookmark" form with styled inputs and a grayed-out Save Bookmark button.
- Fill in a title, URL, and category, then click Save Bookmark.
- You should be redirected back to the bookmark list with your new bookmark visible.
Don't see the form when you click + New Bookmark?
Check that app.routes.ts imports AddBookmark from './add-bookmark/add-bookmark' and includes the route { path: 'add', component: AddBookmark }.
If the page is blank, open the browser console and look for import errors. The file path must match exactly (no .component suffix with Angular v22's naming style).
Help me debug my routing setup
Why template-driven forms?
Angular offers two form approaches: template-driven (using FormsModule) and reactive (using ReactiveFormsModule).
Template-driven forms keep the logic in the HTML, which makes them simpler for straightforward forms like this one. For a first Angular project, template-driven forms teach the core binding concepts more clearly.
✔️ Awesome, I've got everything!
Great. Double check you have saved all three files in src/app/add-bookmark/.
ⓧ I'd like to double check the full code
Here are the complete files for your add-bookmark component:
import { Component, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { BookmarkService } from '../bookmark.service';
@Component({
selector: 'app-add-bookmark',
imports: [FormsModule],
templateUrl: './add-bookmark.html',
styleUrl: './add-bookmark.scss',
})
export class AddBookmark {
private bookmarkService = inject(BookmarkService);
private router = inject(Router);
categories = this.bookmarkService.categories;
bookmark = {
title: '',
url: '',
category: '',
};
newCategory = '';
showNewCategory = false;
onSubmit() {
const category = this.showNewCategory ? this.newCategory : this.bookmark.category;
this.bookmarkService.addBookmark({
title: this.bookmark.title,
url: this.bookmark.url,
category: category,
});
this.router.navigate(['/']);
}
}
<h2>Add New Bookmark</h2>
<form #bookmarkForm="ngForm" (ngSubmit)="onSubmit()">
<div class="form-group">
<label for="title">Title</label>
<input
type="text"
id="title"
name="title"
[(ngModel)]="bookmark.title"
required
placeholder="e.g. Angular Documentation"
/>
</div>
<div class="form-group">
<label for="url">URL</label>
<input
type="url"
id="url"
name="url"
[(ngModel)]="bookmark.url"
required
placeholder="https://example.com"
/>
</div>
<div class="form-group">
<label for="category">Category</label>
@if (!showNewCategory) {
<select id="category" name="category" [(ngModel)]="bookmark.category" required>
<option value="" disabled>Select a category</option>
@for (cat of categories(); track cat) {
<option [value]="cat">{{ cat }}</option>
}
</select>
<button type="button" class="link-button" (click)="showNewCategory = true">
+ New category
</button>
} @else {
<input
type="text"
id="newCategory"
name="newCategory"
[(ngModel)]="newCategory"
required
placeholder="Enter new category"
/>
<button type="button" class="link-button" (click)="showNewCategory = false">
Use existing
</button>
}
</div>
<button type="submit" class="submit-button" [disabled]="!bookmarkForm.form.valid">
Save Bookmark
</button>
</form>
h2 {
margin: 0 0 1.5rem;
font-weight: 600;
}
form {
max-width: 500px;
}
.form-group {
margin-bottom: 1.25rem;
label {
display: block;
margin-bottom: 0.375rem;
font-size: 0.875rem;
font-weight: 500;
color: #333;
}
input,
select {
width: 100%;
padding: 0.625rem 0.75rem;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 0.9375rem;
transition: border-color 0.2s;
box-sizing: border-box;
&:focus {
outline: none;
border-color: #0f3460;
}
}
.link-button {
display: inline-block;
margin-top: 0.5rem;
background: none;
border: none;
color: #0f3460;
font-size: 0.8125rem;
cursor: pointer;
padding: 0;
&:hover {
text-decoration: underline;
}
}
}
.submit-button {
padding: 0.75rem 1.5rem;
background: #0f3460;
color: #fff;
border: none;
border-radius: 6px;
font-size: 1rem;
cursor: pointer;
transition: background 0.2s;
&:hover:not(:disabled) {
background: #16213e;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
Your form is working end-to-end. Users can now add bookmarks with a title, URL, and category. Next up, you'll tackle the Secret Mission and add editing capabilities with route parameters.
Secret mission
Add an Edit Bookmark Feature
Your Bookmark Manager lets you add bookmarks, but what about fixing a typo in an existing one? In this secret mission, you'll build an Edit feature that reads a bookmark's ID from the URL, pre-fills a form with its data, and saves the update back to the signal array.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely on your local machine, so there are no ongoing costs or cloud resources to worry about. Decide whether to keep your files for future reference, pause the running server, or delete everything entirely.
Resources you used:
- The bookmark-manager project folder on your Desktop.
- Angular CLI installed globally via npm.
- The development server running at http://localhost:4200.
Keep everything running
No action needed. Choose this if you're still actively building or want to keep experimenting with your Bookmark Manager app.
- Your dev server stays running at http://localhost:4200.
- Your project files remain in the bookmark-manager folder on your Desktop.
- The Angular CLI stays installed globally and available for future projects.
Pause - I'll come back to this later
Stop the running dev server to free up your terminal and the port. Your project files stay intact so you can pick up where you left off.
- Switch back to the terminal where the dev server is running.
- Press Ctrl+C to stop the server.
- When you're ready to return, navigate back to the bookmark-manager folder and run npm start again.
Delete - I don't want to use this again
Remove all project files and the globally installed Angular CLI to start completely fresh.
- Switch back to the terminal where the dev server is running and press Ctrl+C to stop the server.
- Delete the project folder by running these commands:
macOS
cd ~/Desktop
rm -rf bookmark-manager
Windows
cd ~\Desktop
rmdir /s /q bookmark-manager
- Uninstall the Angular CLI globally by running:
npm uninstall -g @angular/cli
- Confirm it was removed by running ng version. You should see a "command not found" message.
Nice Work!
Nice Work!
Nice work! You just built a fully functional Bookmark Manager single-page application from scratch using Angular v22.
Here's what you learned:
- Built a complete Angular v22 single-page application with standalone components using the modern 2025 file naming convention.
- Used Signals (signal() and computed()) for reactive state management and built template-driven forms with two-way binding and validation.
- Implemented client-side routing with the Angular Router and used built-in control flow (@if, @for, @empty) for conditional and list rendering.
- Secret Mission: Added edit functionality with route parameters and pre-filled forms.
Ready to quiz yourself?