The post detail page ties together several advanced Angular patterns: route parameter binding via @Input() (Angular 18), server-rendered HTML content (requires DomSanitizer), fire-and-forget view count increment, related posts sidebar, and dynamic page title. The post detail is the most performance-sensitive page — it needs to be fast, accessible, and handle edge cases (post not found, deleted post) gracefully.
Post Detail Component
import { Title } from '@angular/platform-browser';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
@Component({
selector: 'app-post-detail',
standalone: true,
imports: [RouterLink, DatePipe, AsyncPipe],
template: `
@if (loading()) {
<app-post-detail-skeleton />
} @else if (post()) {
<article class="post-detail">
<!-- Breadcrumb ──────────────────────────────────────────────────── -->
@if (post()!.categorySlug) {
<nav class="breadcrumb">
<a routerLink="/posts">Posts</a> ›
<a [routerLink]="[]" [queryParams]="{ category: post()!.categorySlug }">
{{ post()!.categoryName }}
</a>
</nav>
}
<h1>{{ post()!.title }}</h1>
<!-- Author card ─────────────────────────────────────────────────── -->
<div class="author-meta">
<img [src]="post()!.authorAvatarUrl" alt="Author" class="avatar">
<div>
<strong>{{ post()!.authorName }}</strong>
<time [dateTime]="post()!.publishedAt">
{{ post()!.publishedAt | date:'longDate' }}
</time>
</div>
</div>
<!-- Tags ────────────────────────────────────────────────────────── -->
<div class="tags">
@for (tag of post()!.tags; track tag.slug) {
<a [routerLink]="[]" [queryParams]="{ tag: tag.slug }">
{{ tag.name }}
</a>
}
</div>
<!-- Post body — server-generated HTML ────────────────────────────── -->
<div class="post-body" [innerHTML]="safeBody()"></div>
<!-- Share buttons ───────────────────────────────────────────────── -->
<div class="share-actions">
<button (click)="copyLink()">📋 Copy Link</button>
<a [href]="twitterShareUrl()" target="_blank" rel="noopener">
𝕏 Share
</a>
</div>
</article>
<!-- Related posts sidebar ───────────────────────────────────────── -->
@if (related().length > 0) {
<aside class="related-posts">
<h3>Related Posts</h3>
@for (rel of related(); track rel.id) {
<app-post-card [post]="rel" />
}
</aside>
}
} @else {
<app-not-found message="Post not found or has been removed." />
}
`,
})
export class PostDetailComponent implements OnInit {
@Input() slug!: string; // bound from :slug route param via withComponentInputBinding
private postsApi = inject(PostsApiService);
private router = inject(Router);
private title = inject(Title);
private sanitizer = inject(DomSanitizer);
private destroyRef = inject(DestroyRef);
post = signal<PostDto | null>(null);
related = signal<PostSummaryDto[]>([]);
loading = signal(true);
// Sanitised HTML for [innerHTML] binding — safe for server-generated content
safeBody = computed(() => {
const body = this.post()?.body;
return body ? this.sanitizer.bypassSecurityTrustHtml(body) : '';
});
twitterShareUrl = computed(() => {
const post = this.post();
if (!post) return '#';
const text = encodeURIComponent(`${post.title} - StackLesson BlogApp`);
const url = encodeURIComponent(window.location.href);
return `https://twitter.com/intent/tweet?text=${text}&url=${url}`;
});
ngOnInit() {
this.postsApi.getBySlug(this.slug).pipe(
takeUntilDestroyed(this.destroyRef),
).subscribe({
next: post => {
this.post.set(post);
this.loading.set(false);
this.title.setTitle(`${post.title} — BlogApp`);
// Fire-and-forget view count increment
this.postsApi.incrementViewCount(post.id).subscribe();
// Load related posts
this.postsApi.getRelated(post.id).subscribe(
rel => this.related.set(rel)
);
},
error: () => {
this.loading.set(false); // post() remains null → shows not-found
},
});
}
copyLink(): void {
navigator.clipboard.writeText(window.location.href)
.then(() => inject(NotificationService).info('Link copied!'));
}
}
bypassSecurityTrustHtml(body) is justified here because the HTML comes from your own server (admin-authored content), not from user comments or external sources. Angular’s default [innerHTML] sanitisation strips some valid formatting tags. However, ensure the API-side sanitises user input when saving the post body — never bypassSecurityTrustHtml with unsanitised user content..subscribe() without any handlers. The component does not care about the result (the view count is not displayed on the detail page in real-time). The request runs in the background, and even if it fails (network error, rate limiting), the user experience is unaffected. This is appropriate for analytics/counter calls that should not block or visually impact the main content display.Title service sets the browser tab title — remember to reset it when navigating away from the post detail page, or implement a global title strategy that sets an appropriate default. If the user navigates from a post (title: “Getting Started with .NET — BlogApp”) back to the post list (title should be “Posts — BlogApp”), Angular will not automatically reset the title — a TitleStrategy implementation or explicit title.setTitle() on other pages is needed.Common Mistakes
Mistake 1 — Using bypassSecurityTrustHtml for user comment content (XSS)
❌ Wrong — bypassing sanitisation on user-submitted comment HTML; stored XSS attack possible.
✅ Correct — bypass only for server-authored content; use Angular’s built-in [innerHTML] sanitisation for any user-provided content.
Mistake 2 — Not setting document title per page (all pages show app name)
❌ Wrong — every page shows “BlogApp” in the browser tab; unhelpful for multi-tab users and poor SEO.
✅ Correct — use Title.setTitle() per page or implement a global TitleStrategy using route data.