Here we are at the final chapter. Nine installments of theory, examples and warnings: now we line everything up and build a real application from beginning to end.
The project is GameShelf, a personal video game library. Search an online database, add a game to your collection, then manage each title's status: to play, playing, completed. The sort of app that looks like a toy but contains all the real problems: authentication, external API calls, shared state, forms and protected navigation.
The rule for this chapter: nothing new. Everything we write comes from an earlier chapter: components (3), router with guards and lazy loading (4), signals (5), forms (6), HTTP and the Resource API (7), state in services (8), interceptors and pipes (9). Consider this your final exam.
๐ฎ What we need
For game search, we use RAWG (rawg.io), the largest video game database with a public API: register and get a free key in a couple of minutes. Login will be fake (no backend, a dummy token in localStorage), but the flow (form, service, guard, redirect) is identical to a real app's: when you have a backend, you will change only one method's contents.
An API key in a frontend app is never truly secret: anyone opening DevTools can see it. For RAWG, that fits this example, but do not commit keys into public repositories, and route calls to sensitive services through your own backend.
๐งฐ Project setup
Start as in chapter 2:
ng new gameshelf --style=css
cd gameshelfWe will not handwrite CSS for the interface: we use Tailwind CSS with daisyUI. Installation:
npm install tailwindcss @tailwindcss/postcss postcss daisyuiThen create .postcssrc.json at the project root:
1{
2 "plugins": {
3 "@tailwindcss/postcss": {}
4 }
5}In src/styles.css, two lines are enough:
@import "tailwindcss";
@plugin "daisyui";๐ผ A few words about daisyUI
Tailwind is a utility-first CSS framework: instead of writing your own classes, compose styles using atomic classes (flex, p-4, text-xl...). Powerful, but a decent button takes ten classes. daisyUI is a plugin that adds predesigned component classes on top of Tailwind:
<button class="btn btn-primary">Save</button>
<div class="card bg-base-100 shadow">...</div>
<span class="badge badge-success">completed</span>btn, card, badge, navbar, input: self-explanatory names, with light and dark themes included. Perfect for a learning project: the appearance is immediately presentable while we stay focused on Angular. Where tweaks are needed, Tailwind utilities work alongside them without conflicts.
๐บ๏ธ The skeleton: routes and configuration
Three pages: login, search and library. The last two are guarded and lazy-loaded, as in chapter 4:
1// app.routes.ts
2import { Routes } from '@angular/router';
3import { authGuard } from './auth/auth.guard';
4
5export const routes: Routes = [
6 { path: '', redirectTo: 'search', pathMatch: 'full' },
7 {
8 path: 'login',
9 loadComponent: () =>
10 import('./auth/login.component').then((m) => m.LoginComponent),
11 },
12 {
13 path: 'search',
14 loadComponent: () =>
15 import('./search/search.component').then((m) => m.SearchComponent),
16 canActivate: [authGuard],
17 },
18 {
19 path: 'library',
20 loadComponent: () =>
21 import('./library/library.component').then((m) => m.LibraryComponent),
22 canActivate: [authGuard],
23 },
24 { path: '**', redirectTo: 'search' },
25];In app.config.ts, register the router, HttpClient and an interceptor we will see shortly:
1import { ApplicationConfig } from '@angular/core';
2import { provideRouter } from '@angular/router';
3import { provideHttpClient, withInterceptors } from '@angular/common/http';
4import { routes } from './app.routes';
5import { rawgKeyInterceptor } from './core/rawg-key.interceptor';
6
7export const appConfig: ApplicationConfig = {
8 providers: [
9 provideRouter(routes),
10 provideHttpClient(withInterceptors([rawgKeyInterceptor])),
11 ],
12};๐ Authentication: service, guard and login
Authentication state lives in a service using chapter 8's pattern: a private signal, public reading through asReadonly(), writes only through methods.
1// auth/auth.service.ts
2import { Injectable, signal } from '@angular/core';
3
4@Injectable({ providedIn: 'root' })
5export class AuthService {
6 private _isLoggedIn = signal(!!localStorage.getItem('gameshelf-token'));
7 readonly isLoggedIn = this._isLoggedIn.asReadonly();
8
9 login(email: string, password: string): boolean {
10 if (!email || !password) return false;
11
12 // a real app would POST to the backend here (chapter 7)
13 localStorage.setItem('gameshelf-token', 'fake-token');
14 this._isLoggedIn.set(true);
15 return true;
16 }
17
18 logout() {
19 localStorage.removeItem('gameshelf-token');
20 this._isLoggedIn.set(false);
21 }
22}The guard is chapter 4's, word for word:
1// auth/auth.guard.ts
2import { inject } from '@angular/core';
3import { CanActivateFn, Router } from '@angular/router';
4import { AuthService } from './auth.service';
5
6export const authGuard: CanActivateFn = () => {
7 return inject(AuthService).isLoggedIn()
8 ? true
9 : inject(Router).parseUrl('/login');
10};The login page is a template-driven form (chapter 6): two fields, two signals, and a submit that delegates to the service and navigates.
1// auth/login.component.ts
2import { Component, inject, signal } from '@angular/core';
3import { FormsModule } from '@angular/forms';
4import { Router } from '@angular/router';
5import { AuthService } from './auth.service';
6
7@Component({
8 selector: 'app-login',
9 imports: [FormsModule],
10 templateUrl: './login.component.html',
11})
12export class LoginComponent {
13 private auth = inject(AuthService);
14 private router = inject(Router);
15
16 email = signal('');
17 password = signal('');
18 errore = signal(false);
19
20 submit() {
21 if (this.auth.login(this.email(), this.password())) {
22 this.router.navigateByUrl('/search');
23 } else {
24 this.errore.set(true);
25 }
26 }
27}1<!-- auth/login.component.html -->
2<div class="min-h-screen flex items-center justify-center bg-base-200">
3 <form
4 (ngSubmit)="submit()"
5 class="card bg-base-100 shadow-xl p-8 w-full max-w-sm flex flex-col gap-4"
6 >
7 <h1 class="text-2xl font-bold">GameShelf ๐ฎ</h1>
8
9 <input
10 name="email"
11 type="email"
12 [(ngModel)]="email"
13 placeholder="Email"
14 class="input input-bordered w-full"
15 required
16 />
17 <input
18 name="password"
19 type="password"
20 [(ngModel)]="password"
21 placeholder="Password"
22 class="input input-bordered w-full"
23 required
24 />
25
26 @if (errore()) {
27 <div class="alert alert-error">Enter an email and password.</div>
28 }
29
30 <button type="submit" class="btn btn-primary">Sign in</button>
31 </form>
32</div>Notice chapter 6's familiar pair: [(ngModel)]="email" (the signal, not a call to it) and the name attribute on every field.
๐ฆ The RAWG key interceptor
RAWG wants the API key as a query parameter on every request. We could manually attach it to every URL... or use what we learned in chapter 9:
1// core/rawg-key.interceptor.ts
2import { HttpInterceptorFn } from '@angular/common/http';
3
4const RAWG_KEY = 'YOUR_RAWG_KEY';
5
6export const rawgKeyInterceptor: HttpInterceptorFn = (req, next) => {
7 if (req.url.startsWith('https://api.rawg.io')) {
8 return next(req.clone({ setParams: { key: RAWG_KEY } }));
9 }
10 return next(req);
11};Every RAWG call now includes the key automatically, and no service needs to know about it. That is the job of interceptors.
๐ The search page
The page's heart is an httpResource (chapter 7) driven by a signal. A design choice: requests start on form submission, not every keystroke. That is why we have two signals: one for typed text and one for the confirmed query.
1// search/search.component.ts
2import { Component, computed, inject, signal } from '@angular/core';
3import { FormsModule } from '@angular/forms';
4import { httpResource } from '@angular/common/http';
5import { DatePipe } from '@angular/common';
6import { LibraryService } from '../library/library.service';
7import { Game, RawgSearchResponse } from '../models/game.model';
8
9@Component({
10 selector: 'app-search',
11 imports: [FormsModule, DatePipe],
12 templateUrl: './search.component.html',
13})
14export class SearchComponent {
15 library = inject(LibraryService);
16
17 testo = signal('');
18 query = signal('');
19
20 risultati = httpResource<RawgSearchResponse>(() =>
21 this.query()
22 ? `https://api.rawg.io/api/games?search=${encodeURIComponent(this.query())}&page_size=12`
23 : undefined
24 );
25
26 giochi = computed(() => this.risultati.value()?.results ?? []);
27
28 cerca() {
29 this.query.set(this.testo().trim());
30 }
31
32 aggiungi(game: Game) {
33 this.library.add(game);
34 }
35}The trick is the function passed to httpResource: if the query is empty, it returns undefined, and Angular simply does not make a request. When the query changes, the request automatically restarts. The models:
1// models/game.model.ts
2export interface Game {
3 id: number;
4 name: string;
5 background_image: string | null;
6 released: string | null;
7 metacritic: number | null;
8}
9
10export interface RawgSearchResponse {
11 results: Game[];
12}And the template, bringing back chapter 5's control flow, chapter 9's date pipe and daisyUI cards:
1<!-- search/search.component.html -->
2<div class="p-6 max-w-5xl mx-auto">
3 <form (ngSubmit)="cerca()" class="flex gap-2 mb-6">
4 <input
5 name="testo"
6 [(ngModel)]="testo"
7 placeholder="Search for a game..."
8 class="input input-bordered flex-1"
9 />
10 <button type="submit" class="btn btn-primary">Search</button>
11 </form>
12
13 @if (risultati.isLoading()) {
14 <span class="loading loading-spinner loading-lg"></span>
15 } @else if (risultati.error()) {
16 <div class="alert alert-error">Something went wrong. Try again.</div>
17 } @else {
18 <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
19 @for (game of giochi(); track game.id) {
20 <div class="card bg-base-100 shadow">
21 @if (game.background_image) {
22 <figure>
23 <img [src]="game.background_image" [alt]="game.name" />
24 </figure>
25 }
26 <div class="card-body">
27 <h2 class="card-title">{{ game.name }}</h2>
28 <p class="text-sm opacity-70">
29 {{ game.released | date: 'longDate' }}
30 </p>
31 <div class="card-actions justify-end">
32 <button
33 class="btn btn-sm btn-primary"
34 [disabled]="library.has(game.id)"
35 (click)="aggiungi(game)"
36 >
37 {{ library.has(game.id) ? 'In your library โ' : 'Add' }}
38 </button>
39 </div>
40 </div>
41 </div>
42 } @empty {
43 @if (query()) {
44 <p>No results for "{{ query() }}".</p>
45 }
46 }
47 </div>
48 }
49</div>The "Add" button checks the library service directly: if the game is already there, it disables itself. Two components, one shared state, zero events to propagate: chapter 8 in action.
๐ The library service
Here is that service. State in a private signal, reads through asReadonly(), statistics derived with computed, and localStorage persistence handled by an effect, exactly where side effects belong, as discussed in chapter 5:
1// library/library.service.ts
2import { computed, effect, Injectable, signal } from '@angular/core';
3import { Game } from '../models/game.model';
4
5export type GameStatus = 'backlog' | 'playing' | 'done';
6
7export interface LibraryGame extends Game {
8 status: GameStatus;
9}
10
11const STORAGE_KEY = 'gameshelf-library';
12
13@Injectable({ providedIn: 'root' })
14export class LibraryService {
15 private _games = signal<LibraryGame[]>(this.load());
16
17 readonly games = this._games.asReadonly();
18 readonly count = computed(() => this._games().length);
19 readonly doneCount = computed(
20 () => this._games().filter((g) => g.status === 'done').length
21 );
22
23 constructor() {
24 // save the library whenever it changes. That is all.
25 effect(() => {
26 localStorage.setItem(STORAGE_KEY, JSON.stringify(this._games()));
27 });
28 }
29
30 has(id: number): boolean {
31 return this._games().some((g) => g.id === id);
32 }
33
34 add(game: Game) {
35 if (this.has(game.id)) return;
36 this._games.update((games) => [...games, { ...game, status: 'backlog' }]);
37 }
38
39 remove(id: number) {
40 this._games.update((games) => games.filter((g) => g.id !== id));
41 }
42
43 setStatus(id: number, status: GameStatus) {
44 this._games.update((games) =>
45 games.map((g) => (g.id === id ? { ...g, status } : g))
46 );
47 }
48
49 private load(): LibraryGame[] {
50 try {
51 return JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '[]');
52 } catch {
53 return [];
54 }
55 }
56}Read the constructor's effect again: it reads _games(), so it reruns on every library change. Add, remove, change status: saving happens automatically, and on restart, load() finds everything. Persistence in six lines.
๐๏ธ The library page
The last page: your collection with a status filter. The filter is a signal and the filtered list a computed: by this point, the reflex should be automatic.
1// library/library.component.ts
2import { Component, computed, inject, signal } from '@angular/core';
3import { GameStatus, LibraryService } from './library.service';
4
5const LABELS: Record<GameStatus, string> = {
6 backlog: 'To play',
7 playing: 'Playing',
8 done: 'Completed',
9};
10
11@Component({
12 selector: 'app-library',
13 templateUrl: './library.component.html',
14})
15export class LibraryComponent {
16 library = inject(LibraryService);
17
18 readonly stati = ['backlog', 'playing', 'done'] as const;
19 readonly labels = LABELS;
20
21 filtro = signal<GameStatus | 'all'>('all');
22
23 giochiFiltrati = computed(() => {
24 const filtro = this.filtro();
25 const games = this.library.games();
26 return filtro === 'all' ? games : games.filter((g) => g.status === filtro);
27 });
28
29 cambiaStato(id: number, event: Event) {
30 const status = (event.target as HTMLSelectElement).value as GameStatus;
31 this.library.setStatus(id, status);
32 }
33}1<!-- library/library.component.html -->
2<div class="p-6 max-w-5xl mx-auto">
3 <div class="flex items-center justify-between mb-6">
4 <h1 class="text-2xl font-bold">
5 La mia libreria
6 <span class="badge badge-primary">{{ library.count() }}</span>
7 <span class="badge badge-success">{{ library.doneCount() }} completed</span>
8 </h1>
9
10 <div class="join">
11 <button
12 class="btn btn-sm join-item"
13 [class.btn-active]="filtro() === 'all'"
14 (click)="filtro.set('all')"
15 >
16 All
17 </button>
18 @for (stato of stati; track stato) {
19 <button
20 class="btn btn-sm join-item"
21 [class.btn-active]="filtro() === stato"
22 (click)="filtro.set(stato)"
23 >
24 {{ labels[stato] }}
25 </button>
26 }
27 </div>
28 </div>
29
30 <div class="flex flex-col gap-3">
31 @for (game of giochiFiltrati(); track game.id) {
32 <div class="card card-side bg-base-100 shadow">
33 @if (game.background_image) {
34 <figure class="w-40">
35 <img
36 [src]="game.background_image"
37 [alt]="game.name"
38 class="h-full object-cover"
39 />
40 </figure>
41 }
42 <div class="card-body flex-row items-center justify-between">
43 <h2 class="card-title">{{ game.name }}</h2>
44 <div class="flex items-center gap-2">
45 <select
46 class="select select-bordered select-sm"
47 [value]="game.status"
48 (change)="cambiaStato(game.id, $event)"
49 >
50 @for (stato of stati; track stato) {
51 <option [value]="stato">{{ labels[stato] }}</option>
52 }
53 </select>
54 <button
55 class="btn btn-sm btn-ghost text-error"
56 (click)="library.remove(game.id)"
57 >
58 Remove
59 </button>
60 </div>
61 </div>
62 </div>
63 } @empty {
64 <p class="opacity-70">
65 Nothing here yet. Go to search and add your first game!
66 </p>
67 }
68 </div>
69</div>Every interaction (changing status, removing, filtering) passes through signals: the list updates, the badges above recalculate, and the effect saves again. No synchronization code, anywhere.
๐งญ The navigation bar
Close the loop in the root component: a navbar with links (and chapter 4's routerLinkActive), the library counter updating from any page, and logout.
1// app.component.ts
2import { Component, inject } from '@angular/core';
3import { Router, RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
4import { AuthService } from './auth/auth.service';
5import { LibraryService } from './library/library.service';
6
7@Component({
8 selector: 'app-root',
9 imports: [RouterOutlet, RouterLink, RouterLinkActive],
10 templateUrl: './app.component.html',
11})
12export class AppComponent {
13 auth = inject(AuthService);
14 library = inject(LibraryService);
15 private router = inject(Router);
16
17 logout() {
18 this.auth.logout();
19 this.router.navigateByUrl('/login');
20 }
21}1<!-- app.component.html -->
2@if (auth.isLoggedIn()) {
3 <div class="navbar bg-base-100 shadow">
4 <div class="flex-1 font-bold">GameShelf ๐ฎ</div>
5 <div class="flex gap-2">
6 <a routerLink="/search" routerLinkActive="btn-active" class="btn btn-ghost btn-sm">
7 Cerca
8 </a>
9 <a routerLink="/library" routerLinkActive="btn-active" class="btn btn-ghost btn-sm">
10 Library
11 <span class="badge badge-sm badge-primary">{{ library.count() }}</span>
12 </a>
13 <button class="btn btn-ghost btn-sm" (click)="logout()">Sign out</button>
14 </div>
15 </div>
16}
17
18<router-outlet />Run ng serve, log in with any credentials, and GameShelf is alive: search for "Hollow Knight", add it, mark it "playing" (let us be honest: it will stay there for months), and watch the navbar badge keep count.
๐ The finish line
Ten chapters ago, Angular was "that enterprise framework with too much stuff in it". Today you have built a complete app: standalone components, protected and lazy routes, reactive state with signals, forms, an external API with an interceptor, persistence. Above all, you have a method: you know where each piece belongs, which separates people copying tutorials from people building software.
From here, set your own assignments: add a detail page with route parameters, a personal rating with reactive forms, or replace the fake login with a real backend. You have all the tools.
Thank you for making it this far. If GameShelf gets out of hand and becomes your next serious project, write to me: those are my favorite stories. ใพใใญ!
