$ cli-blog docs
Examples & Guides

Add a blog to Angular

Create posts through a backend route, then render published Cli Blog content in Angular.

Build the Angular UI around a backend-for-frontend (BFF). Angular requests your /api/blog/* routes; the BFF reads published content from Cli Blog and handles any trusted writes.

Choose the key boundary

LocationKeyAccess
Angular applicationNoneRender responses from your BFF
BFF delivery routesPublic keyRead published posts and discovery XML
Protected BFF routesPrivate keyCreate drafts after application-level authorization

Public keys can read published content from a browser, but a BFF gives you one place for caching and rotation. Never add a private key to environment.ts, angular.json, or any value compiled into the browser bundle.

Install Markdown dependencies

Use a Markdown parser and sanitize its HTML before binding it:

npm install marked dompurify

Configure the BFF environment

Store keys in the server environment:

CLI_BLOG_PUBLIC_KEY=<public-api-key>
CLI_BLOG_PRIVATE_KEY=<private-api-key>

The private key should receive only the permissions needed by your editorial workflow. See API keys.

Create the Angular data types

export type PostSummary = {
  id: string;
  title: string;
  slug: string;
  excerpt: string | null;
  published_at: string | null;
};

export type Post = PostSummary & {
  body_markdown: string | null;
  seo_title?: string | null;
  seo_description?: string | null;
  canonical_url?: string | null;
};

export type PostList = {
  data: PostSummary[];
  has_more: boolean;
  next_cursor: string | null;
};

Build the blog service

Register provideHttpClient() in the application config, then create a service:

import { HttpClient, HttpParams } from "@angular/common/http";
import { inject, Injectable } from "@angular/core";

@Injectable({ providedIn: "root" })
export class BlogService {
  private readonly http = inject(HttpClient);

  list(after?: string) {
    const params = after ? new HttpParams().set("after", after) : undefined;
    return this.http.get<PostList>("/api/blog/posts", { params });
  }

  get(slug: string) {
    return this.http.get<Post>(`/api/blog/posts/${encodeURIComponent(slug)}`);
  }
}

Implement the BFF list route

Forward only supported query values. Do not pass arbitrary browser query strings to the upstream API.

const url = new URL("https://api.cli-blog.com/v1/posts");
url.searchParams.set("status", "published");
url.searchParams.set("locale", "en-US");
url.searchParams.set("fields", "summary,seo");
url.searchParams.set("include", "authors,tags");
url.searchParams.set("limit", "12");
if (after) url.searchParams.set("after", after);

const response = await fetch(url, {
  headers: { "x-api-key": process.env.CLI_BLOG_PUBLIC_KEY! },
});

Field groups control post properties. summary is suited to list cards, content adds Markdown, and seo adds metadata. Includes embed related authors, categories, tags, media, or translations. Read Posts for the complete table.

Build the detail route with GET /v1/posts/{slug} and request fields=summary,content,seo&include=authors,categories,tags,media.

Render the blog index

import { Component, inject, OnInit } from "@angular/core";
import { RouterLink } from "@angular/router";

@Component({
  selector: "app-blog-index",
  imports: [RouterLink],
  template: `
    @if (error) { <p role="alert">{{ error }}</p> }
    @for (post of posts; track post.id) {
      <article>
        <h2><a [routerLink]="['/blog', post.slug]">{{ post.title }}</a></h2>
        @if (post.excerpt) { <p>{{ post.excerpt }}</p> }
      </article>
    }
    @if (nextCursor) {
      <button type="button" (click)="load(nextCursor)">Load more</button>
    }
  `,
})
export class BlogIndexComponent implements OnInit {
  private readonly blog = inject(BlogService);
  posts: PostSummary[] = [];
  nextCursor: string | null = null;
  error = "";

  ngOnInit() { this.load(); }

  load(after?: string) {
    this.blog.list(after).subscribe({
      next: (page) => {
        this.posts = after ? [...this.posts, ...page.data] : page.data;
        this.nextCursor = page.has_more ? page.next_cursor : null;
      },
      error: () => { this.error = "Could not load posts."; },
    });
  }
}

Cursor pagination is the recommended option for load-more interfaces. Keep all filters unchanged when you send next_cursor as after; the maximum limit is 100. See Pagination.

Add the slug route

Register routes for the index and detail components:

export const routes: Routes = [
  { path: "blog", component: BlogIndexComponent },
  { path: "blog/:slug", component: BlogPostComponent },
];

Read the slug and load the post:

private readonly route = inject(ActivatedRoute);
private readonly blog = inject(BlogService);

ngOnInit() {
  const slug = this.route.snapshot.paramMap.get("slug");
  if (slug) this.blog.get(slug).subscribe((post) => this.setPost(post));
}

Handle a 404 with your not-found route rather than rendering an empty article.

Render Markdown safely

Parse and sanitize before binding the result:

import DOMPurify from "dompurify";
import { marked } from "marked";

async setPost(post: Post) {
  this.post = post;
  const html = await marked.parse(post.body_markdown ?? "");
  this.postHtml = DOMPurify.sanitize(html);
}
<article>
  <h1>{{ post.title }}</h1>
  <div [innerHTML]="postHtml"></div>
</article>

Never pass untrusted output through bypassSecurityTrustHtml. Review allowed tags and URL protocols if you customize DOMPurify.

Set SEO metadata

Use Angular’s Title and Meta services after the detail response arrives:

this.title.setTitle(post.seo_title ?? post.title);
this.meta.updateTag({ name: "description", content: post.seo_description ?? post.excerpt ?? "" });

Set a canonical <link> through your document-head strategy. If you use Angular SSR, make sure metadata is present in the server-rendered response instead of adding it only after hydration.

Create trusted drafts

After your BFF verifies the current user and validates input, create a reviewable draft:

await fetch("https://api.cli-blog.com/v1/posts", {
  method: "POST",
  headers: {
    "content-type": "application/json",
    "x-api-key": process.env.CLI_BLOG_PRIVATE_KEY!,
  },
  body: JSON.stringify({
    title: input.title,
    body_markdown: input.markdown,
    locale: "en-US",
    status: "draft",
  }),
});

Add your own session check, role check, CSRF defense, input limits, and rate limits. A browser should never submit directly with the private key.

Cache and recover from errors

Cache successful published reads at the BFF with a short shared TTL and stale revalidation. Purge the list and slug after publication. Map upstream 404 to the Angular not-found state, let 429 honor Retry-After, and avoid caching temporary 5xx responses.

Production checklist

  • Keep private keys out of Angular environments, browser bundles, logs, and source maps.
  • Request lean list fields and full content only for a detail route.
  • Sanitize parsed Markdown and never bypass Angular’s HTML security.
  • Keep cursor filters stable and prevent duplicate items while loading more.
  • Add loading, empty, error, retry, and not-found states.
  • Render metadata during SSR when search discovery matters.
  • Revalidate cached pages after publish, schedule, archive, or slug changes.
  • Expose sitemap and feed through the server that hosts the application.

On this page