LearnAI ToolsCareerPractice BuildsPlayContact
AngularIntermediate~2.5 hours

Multi-Page Blog

Build a blog with routing between a post list and detail pages.

Angular RouterRoute ParametersComponents

Overview

Every Angular app so far in this course has been a single view. A blog is the natural project for introducing multiple pages, because it has an obvious two-level structure: a list page showing every post, and a detail page showing one post in full — and clicking a post has to change the URL, not just swap out some local state, so the browser's back button, refresh, and shareable links all keep working correctly.

This tutorial uses the Angular Router, configured entirely without an `NgModule` — `provideRouter(routes)` registers a route table when the app bootstraps, `<router-outlet>` renders whichever page component matches the current URL, `routerLink` changes the URL without a full page reload, `ActivatedRoute` reads dynamic segments like a post's id out of the URL, and `Router` triggers a route change from code (for a "Back" button, for example).

What You'll Build
  • A `PostListComponent` page that renders every post as a preview card linking to its own URL.
  • A `PostDetailComponent` page reached at `/posts/:postId`, showing one post's full content.
  • Real client-side navigation with `routerLink` — no full-page reloads between the list and a post.
  • `ActivatedRoute`, bridged with `toSignal()`, to read which post to display straight out of the current URL's dynamic `:postId` segment.
  • A "Back to all posts" button built with `Router.navigate()` for imperative navigation.
  • A route configuration in `app.routes.ts` mapping each URL pattern to its page component.

Prerequisites

  • Standalone components and `@Input()`/`@Output()` — covered in the Todo App and Shopping Cart projects above.
  • `computed()` and `toSignal()` — used to bridge the active route into a signal, first introduced in the Movie Search App project.
  • Array methods — `find`, used to look up a post by id.
  • Basic familiarity with URLs — paths, and dynamic segments like `:postId`.
  • The Angular Router itself is new here — every function and directive it provides is introduced from scratch in this tutorial.

Project Structure

Post data lives in its own file as a plain array, since — like the product catalog in the Shopping Cart project — it does not change while the app runs. `PostListComponent` and `PostDetailComponent` are two separate page components, each rendered by `<router-outlet>` when its route matches, and each reading the data it needs from the same shared `POSTS` array rather than through `@Input()`s passed down a component tree.

src/app/
posts.ts // Static array of blog posts
app.routes.ts // Route table mapping URLs to the two page components
post-list/
post-list.component.ts // "/" — every post as a preview card, each linking to its detail page
post-list.component.html
post-detail/
post-detail.component.ts // "/posts/:postId" — one post's full content, looked up by the route param
post-detail.component.html
app.component.ts // Just a <router-outlet> — the router decides what renders inside it
app.component.css // Shared dark-theme styling
main.ts // Bootstraps <AppComponent>, registers the router via provideRouter()
/* app.component.css */
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: Arial, Helvetica, sans-serif;
background: #1a1d23;
color: #e8e8e8;
display: flex;
justify-content: center;
padding: 40px 16px;
}
.blog-app { width: 100%; max-width: 560px; }
.blog-app h1 { margin-bottom: 20px; font-size: 1.6rem; }
.post-preview {
background: #23262e;
border-radius: 8px;
padding: 16px 18px;
margin-bottom: 14px;
}
.post-preview h2 { font-size: 1.1rem; margin-bottom: 6px; }
.post-preview a { color: #e8e8e8; text-decoration: none; }
.post-preview a:hover { color: #dd0031; }
.post-author { color: #9aa4b2; font-size: 0.85rem; margin-top: 6px; }
.post-detail { background: #23262e; border-radius: 8px; padding: 20px; }
.post-detail h1 { font-size: 1.4rem; margin-bottom: 8px; }
.post-detail p { line-height: 1.6; margin-top: 10px; }
.back-btn {
background: transparent;
border: 1px solid #3a3f4b;
color: #9aa4b2;
border-radius: 6px;
padding: 6px 12px;
cursor: pointer;
margin-bottom: 16px;
}

Step 1: Define the Posts Data and Route Plan

Each post gets a URL-friendly `id` string (not just a numeric index) so links stay meaningful and stable even if posts are reordered or added later. Sketching the two routes out in a comment before writing any component code is worth doing deliberately — it is what decides what each page component needs to read from the URL.

// posts.ts
export interface Post {
id: string;
title: string;
excerpt: string;
body: string;
author: string;
}
export const POSTS: Post[] = [
{
id: 'angular-signals',
title: 'Understanding Angular Signals',
excerpt: 'A practical tour of signal, computed, and effect, and how Angular tracks changes under the hood.',
body: 'A signal is a reactive container around a value. Calling it as a function (signal()) inside a template or a computed() registers that read as a dependency, and calling .set()/.update() on it later notifies exactly the consumers that read it to recompute or re-render.',
author: 'Dana Lee',
},
{
id: 'css-grid',
title: 'CSS Grid in 10 Minutes',
excerpt: 'The handful of Grid properties that cover almost every real layout.',
body: 'CSS Grid turns layout into a two-dimensional problem you describe once on a container, instead of a chain of floats or flex wrappers. grid-template-columns and gap alone solve most everyday page layouts.',
author: 'Sam Ortiz',
},
{
id: 'rxjs-basics',
title: 'RxJS Observables Explained',
excerpt: 'How Observables, operators, and subscriptions actually fit together.',
body: 'An Observable is a stream of values delivered over time, and an operator like map or switchMap builds a new Observable from an existing one without ever changing the original — the same "always return something new" discipline signals use for updates.',
author: 'Priya Nair',
},
];
/*
Route plan:
"" -> PostListComponent (every post's title + excerpt, each linking out)
"posts/:postId" -> PostDetailComponent (one post's full body, looked up by the :postId URL segment)
*/

Step 2: Configure Routes with provideRouter

The Angular Router is configured as a plain array of `Routes` and registered once, in `main.ts`, via `provideRouter(routes)` — there is no `RouterModule.forRoot()` needed in a standalone-components app, since that was the `NgModule`-based way of doing the exact same registration.

// app.routes.ts
import { Routes } from '@angular/router';
import { PostListComponent } from './post-list/post-list.component';
import { PostDetailComponent } from './post-detail/post-detail.component';
export const routes: Routes = [
{ path: '', component: PostListComponent },
{ path: 'posts/:postId', component: PostDetailComponent }, // :postId is a placeholder matching any value in that URL segment
];
// main.ts
import { bootstrapApplication } from '@angular/platform-browser';
import { provideRouter } from '@angular/router';
import { AppComponent } from './app/app.component';
import { routes } from './app/app.routes';
bootstrapApplication(AppComponent, {
providers: [provideRouter(routes)], // registers the route table so router-outlet/routerLink/ActivatedRoute/Router all work
}).catch((err) => console.error(err));

Step 3: Build the Post List Page

`routerLink` renders an `<a>` under the hood, but intercepts the click to swap the page's content through the Angular Router instead of asking the browser to fully reload — that is what keeps the app feeling instant while still producing a real, bookmarkable URL for every post. `[routerLink]="['/posts', post.id]"` builds the URL from an array of path segments, which is safer than string-concatenating a URL by hand since the Router escapes each segment itself.

// post-list/post-list.component.ts
import { Component } from '@angular/core';
import { RouterLink } from '@angular/router';
import { POSTS } from '../posts';
@Component({
selector: 'app-post-list',
standalone: true,
imports: [RouterLink],
templateUrl: './post-list.component.html',
})
export class PostListComponent {
protected readonly posts = POSTS;
}
<!-- post-list/post-list.component.html -->
<div class="post-list">
<h1>Blog</h1>
@for (post of posts; track post.id) {
<article class="post-preview">
<h2>
<!-- routerLink swaps the rendered page without a full browser
reload — a plain <a href="..."> would re-download the whole app -->
<a [routerLink]="['/posts', post.id]">{{ post.title }}</a>
</h2>
<p>{{ post.excerpt }}</p>
<p class="post-author">By {{ post.author }}</p>
</article>
}
</div>

Step 4: Build the Post Detail Page with a Route Parameter

`ActivatedRoute.paramMap` is an Observable that emits whenever the URL's params change. Piping it through `map()` to pull out just `postId`, then through `toSignal()`, produces a signal the rest of the component can read the normal way. Wrapping the lookup in `computed()` means it re-derives automatically if the route ever changes while this component stays mounted — navigating from one post's URL directly to another, for example, without the component being destroyed and recreated in between.

// post-detail/post-detail.component.ts
import { Component, computed, inject } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { toSignal } from '@angular/core/rxjs-interop';
import { map } from 'rxjs/operators';
import { POSTS } from '../posts';
@Component({
selector: 'app-post-detail',
standalone: true,
templateUrl: './post-detail.component.html',
})
export class PostDetailComponent {
private readonly route = inject(ActivatedRoute); // pulls the current route (including its params) reactively
// paramMap emits a new value every time the URL's params change —
// toSignal() bridges that stream into a signal, the same technique used
// for movies$ in the Movie Search App project
private readonly postId = toSignal(
this.route.paramMap.pipe(map((params) => params.get('postId'))),
{ initialValue: null }
);
// Re-derives automatically if postId() ever changes while this component
// stays mounted
readonly post = computed(() => POSTS.find((p) => p.id === this.postId()));
}
<!-- post-detail/post-detail.component.html -->
<!-- Handles a mistyped or stale URL gracefully instead of crashing on post()!.title -->
@if (!post()) {
<p>Post not found.</p>
} @else {
<article class="post-detail">
<h1>{{ post()!.title }}</h1>
<p class="post-author">By {{ post()!.author }}</p>
<p>{{ post()!.body }}</p>
</article>
}

Step 5: Navigate Back with the Router

`routerLink` is the right tool when the user clicks something that looks and behaves like a link. `inject(Router)` (the router service itself, distinct from `ActivatedRoute`, which describes the *current* route) is for triggering a route change from inside a method — here, a plain `<button>` that calls `router.navigate(['/'])` when clicked, which is a more natural fit than styling a `routerLink` to look like a button.

// post-detail/post-detail.component.ts (updated)
import { Component, computed, inject } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { toSignal } from '@angular/core/rxjs-interop';
import { map } from 'rxjs/operators';
import { POSTS } from '../posts';
@Component({
selector: 'app-post-detail',
standalone: true,
templateUrl: './post-detail.component.html',
})
export class PostDetailComponent {
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router); // imperative navigation — call it from code, not by rendering a routerLink
private readonly postId = toSignal(
this.route.paramMap.pipe(map((params) => params.get('postId'))),
{ initialValue: null }
);
readonly post = computed(() => POSTS.find((p) => p.id === this.postId()));
goBack(): void {
this.router.navigate(['/']);
}
}
<!-- post-detail/post-detail.component.html (updated) -->
@if (!post()) {
<p>Post not found.</p>
} @else {
<article class="post-detail">
<button type="button" class="back-btn" (click)="goBack()">&larr; Back to all posts</button>
<h1>{{ post()!.title }}</h1>
<p class="post-author">By {{ post()!.author }}</p>
<p>{{ post()!.body }}</p>
</article>
}

Step 6: Add router-outlet to the Root Component

`<router-outlet>` is a placeholder — the Router matches the current browser URL against every `path` in the route table configured in Step 2 and renders the matching `component` right there. `AppComponent` itself never needs to know which page is showing; it only needs to import `RouterOutlet` and render the one element.

// app.component.ts
import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';
@Component({
selector: 'app-root',
standalone: true,
imports: [RouterOutlet],
templateUrl: './app.component.html',
styleUrls: ['./app.component.css'],
})
export class AppComponent {}
<!-- app.component.html -->
<div class="blog-app">
<!-- router-outlet renders whichever page component matches the current URL -->
<router-outlet />
</div>

Complete Code

Here is the complete project — every file, fully assembled from the steps above, using the real Angular Router exactly as you would configure it in a bootstrapped, standalone-components app.

// posts.ts
export interface Post {
id: string;
title: string;
excerpt: string;
body: string;
author: string;
}
export const POSTS: Post[] = [
{
id: 'angular-signals',
title: 'Understanding Angular Signals',
excerpt: 'A practical tour of signal, computed, and effect, and how Angular tracks changes under the hood.',
body: 'A signal is a reactive container around a value. Calling it as a function inside a template or a computed() registers that read as a dependency, and calling .set()/.update() on it later notifies exactly the consumers that read it.',
author: 'Dana Lee',
},
{
id: 'css-grid',
title: 'CSS Grid in 10 Minutes',
excerpt: 'The handful of Grid properties that cover almost every real layout.',
body: 'CSS Grid turns layout into a two-dimensional problem you describe once on a container.',
author: 'Sam Ortiz',
},
{
id: 'rxjs-basics',
title: 'RxJS Observables Explained',
excerpt: 'How Observables, operators, and subscriptions actually fit together.',
body: 'An Observable is a stream of values delivered over time, and an operator like map or switchMap builds a new Observable from an existing one.',
author: 'Priya Nair',
},
];
// app.routes.ts
import { Routes } from '@angular/router';
import { PostListComponent } from './post-list/post-list.component';
import { PostDetailComponent } from './post-detail/post-detail.component';
export const routes: Routes = [
{ path: '', component: PostListComponent },
{ path: 'posts/:postId', component: PostDetailComponent },
];
// post-list/post-list.component.ts
import { Component } from '@angular/core';
import { RouterLink } from '@angular/router';
import { POSTS } from '../posts';
@Component({
selector: 'app-post-list',
standalone: true,
imports: [RouterLink],
templateUrl: './post-list.component.html',
})
export class PostListComponent {
protected readonly posts = POSTS;
}
<!-- post-list/post-list.component.html -->
<div class="post-list">
<h1>Blog</h1>
@for (post of posts; track post.id) {
<article class="post-preview">
<h2><a [routerLink]="['/posts', post.id]">{{ post.title }}</a></h2>
<p>{{ post.excerpt }}</p>
<p class="post-author">By {{ post.author }}</p>
</article>
}
</div>
// post-detail/post-detail.component.ts
import { Component, computed, inject } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { toSignal } from '@angular/core/rxjs-interop';
import { map } from 'rxjs/operators';
import { POSTS } from '../posts';
@Component({
selector: 'app-post-detail',
standalone: true,
templateUrl: './post-detail.component.html',
})
export class PostDetailComponent {
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly postId = toSignal(
this.route.paramMap.pipe(map((params) => params.get('postId'))),
{ initialValue: null }
);
readonly post = computed(() => POSTS.find((p) => p.id === this.postId()));
goBack(): void {
this.router.navigate(['/']);
}
}
<!-- post-detail/post-detail.component.html -->
@if (!post()) {
<p>Post not found.</p>
} @else {
<article class="post-detail">
<button type="button" class="back-btn" (click)="goBack()">&larr; Back to all posts</button>
<h1>{{ post()!.title }}</h1>
<p class="post-author">By {{ post()!.author }}</p>
<p>{{ post()!.body }}</p>
</article>
}
// app.component.ts
import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';
@Component({
selector: 'app-root',
standalone: true,
imports: [RouterOutlet],
templateUrl: './app.component.html',
styleUrls: ['./app.component.css'],
})
export class AppComponent {}
<!-- app.component.html -->
<div class="blog-app">
<router-outlet />
</div>
// main.ts
import { bootstrapApplication } from '@angular/platform-browser';
import { provideRouter } from '@angular/router';
import { AppComponent } from './app/app.component';
import { routes } from './app/app.routes';
bootstrapApplication(AppComponent, {
providers: [provideRouter(routes)],
}).catch((err) => console.error(err));

Sample Run

Sample Interaction

Click Run to see what this code prints.

Extend This Project

  • Add a wildcard route (`{ path: '**', component: NotFoundComponent }`) for any URL that does not match the other routes.
  • Add a category filter using a query parameter (e.g. `/?category=angular`) read with `route.queryParamMap`.
  • Wrap `PostListComponent` and `PostDetailComponent` in a shared layout (header/footer) that stays outside `<router-outlet>` in `AppComponent`.
  • Add a simple "New Post" form at `/new` that calls `router.navigate(['/posts', newPost.id])` after submitting.
  • Highlight the currently active link in a nav bar using `routerLinkActive`, the Router's built-in directive for exactly this.

Summary

You built a multi-page Angular app using `provideRouter()` to map URLs to page components, `routerLink` for real, no-reload navigation, `ActivatedRoute` bridged through `toSignal()` to read a post's id out of the current URL's dynamic segment, and `Router.navigate()` for navigation triggered from code. This routing pattern — a list page linking into detail pages identified by a URL param — is the backbone of nearly every content-driven Angular app you'll build beyond this course, from blogs to e-commerce product pages to documentation sites.