Angular Post Detail — Full Post View with Related Content

📋 Table of Contents
  1. Post Detail Component
  2. Common Mistakes

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!'));
  }
}
Note: The post body is server-generated HTML (from a rich text editor like Quill or TipTap on the admin side). Using 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.
Tip: The view count increment is a “fire-and-forget” call — .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.
Warning: The 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.

🧠 Test Yourself

The view count increment call uses .subscribe() with no handlers. If the API returns a 429 (Too Many Requests), what happens to the PostDetailComponent?