$ cli-blog docs
Examples & Guides

Add a blog to Astro.js

Create posts from trusted code, fetch published Cli Blog content during Astro builds, and expose XML routes.

Astro can turn published Cli Blog posts into static pages at build time or fetch them from server-rendered routes. In both modes, keep private publishing keys in trusted server or CI code.

Choose static or server rendering

ModeGood fitUpdate behavior
Static outputDocumentation, product blogs, release notesRebuild after publication
Server outputFrequently changing or personalized sitesCache responses and revalidate at runtime

This tutorial starts with static output. The same delivery helpers work in server-rendered pages when your adapter runs Node.js 20 or newer.

Install the dependencies

Use the Node SDK for typed delivery reads and sanitize parsed Markdown:

npm install @cli-blog/node marked sanitize-html
npm install --save-dev @types/sanitize-html

Configure environment variables

Add local keys to .env and deployment values to the host’s secret manager:

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

Only variables prefixed with PUBLIC_ are available to browser code in Astro. Do not use that prefix for a private key. See API keys.

Create the public client

Add src/lib/blog.ts:

import { CliBlog } from "@cli-blog/node";

export const publicBlog = new CliBlog({
  apiKey: import.meta.env.CLI_BLOG_PUBLIC_KEY,
});

The SDK defaults to https://api.cli-blog.com. A public key limits this client to published delivery reads.

Build the blog index

Add src/pages/blog/index.astro:

---
import { publicBlog } from "../../lib/blog";

const posts = await publicBlog.posts.list({
  status: "published",
  locale: "en-US",
  fields: ["summary", "seo"],
  include: ["authors", "tags"],
  limit: 12,
});
---

<html lang="en">
  <head>
    <title>Blog</title>
    <meta name="description" content="Product news, guides, and release notes." />
  </head>
  <body>
    <main>
      <h1>Blog</h1>
      {posts.data.map((post) => (
        <article>
          <h2><a href={`/blog/${post.slug}/`}>{post.title}</a></h2>
          {post.excerpt && <p>{post.excerpt}</p>}
        </article>
      ))}
    </main>
  </body>
</html>

summary adds list-card fields, seo adds metadata, and includes embed related resources. Add content only on detail pages. Read Posts for every field group and include.

Generate every slug page

Add src/pages/blog/[slug].astro:

---
import { marked } from "marked";
import sanitizeHtml from "sanitize-html";
import { publicBlog } from "../../lib/blog";

export async function getStaticPaths() {
  const paths = [];
  for await (const post of publicBlog.posts.paginate({
    status: "published",
    locale: "en-US",
    fields: ["summary"],
    limit: 100,
  })) {
    if (post.slug) paths.push({ params: { slug: post.slug } });
  }
  return paths;
}

const slug = Astro.params.slug!;
const post = await publicBlog.posts.get(slug, {
  locale: "en-US",
  fields: ["summary", "content", "seo"],
  include: ["authors", "categories", "tags", "media"],
});

const parsed = await marked.parse(post.body_markdown ?? "");
const bodyHtml = sanitizeHtml(parsed);
const title = post.seo_title ?? post.title;
const description = post.seo_description ?? post.excerpt ?? "";
---

<html lang={post.locale}>
  <head>
    <title>{title}</title>
    <meta name="description" content={description} />
    {post.canonical_url && <link rel="canonical" href={post.canonical_url} />}
    {post.robots_index === false && <meta name="robots" content="noindex, nofollow" />}
  </head>
  <body>
    <main>
      <article>
        <h1>{post.title}</h1>
        <div class="prose" set:html={bodyHtml} />
      </article>
    </main>
  </body>
</html>

paginate() follows cursor pages until completion, so static generation does not depend on slower numbered pagination. The maximum page size is 100. Read Pagination for cursor invariants.

Parse and sanitize before using set:html. Keep URL protocols and allowed tags narrow if you customize sanitize-html; never pass body_markdown directly to set:html.

Add sitemap output

Create src/pages/sitemap.xml.ts:

import { publicBlog } from "../lib/blog";

export async function GET() {
  const xml = await publicBlog.sitemap.get({ locale: "en-US", limit: 100 });
  return new Response(xml, {
    headers: { "content-type": "application/xml; charset=utf-8" },
  });
}

Link https://your-domain.com/sitemap.xml from robots.txt.

Add the RSS feed

Create src/pages/feed.xml.ts:

import { publicBlog } from "../lib/blog";

export async function GET() {
  const xml = await publicBlog.feed.get({ locale: "en-US", limit: 100 });
  return new Response(xml, {
    headers: { "content-type": "application/rss+xml; charset=utf-8" },
  });
}

Add an alternate feed link in your layout head so browsers and feed readers can discover it.

Create drafts from CI or server code

Build a separate private client only in a trusted script, endpoint, or server action:

import { CliBlog } from "@cli-blog/node";

const adminBlog = new CliBlog({
  apiKey: process.env.CLI_BLOG_PRIVATE_KEY!,
});

const draft = await adminBlog.posts.create({
  title: "Release notes",
  body_markdown: markdown,
  locale: "en-US",
  status: "draft",
});

For a web endpoint, add your own session, role, CSRF, schema validation, and rate-limit checks first. Keep publication as a separate reviewed status change.

Rebuild or revalidate

For static output, trigger a new build after a post is published, archived, rescheduled, or moved to a new slug. Debounce rapid editorial events so one release does not start several overlapping builds.

For server output, cache successful published responses with a short shared TTL and stale revalidation. Purge the list and affected slug after publication. Keep private write routes uncached, and do not cache upstream errors as empty pages.

Handle failures

Treat 404 as a not-found page, 401 as a key configuration problem, 403 as a scope problem, and 429 as a signal to honor Retry-After. Fail a static build when content cannot be fetched instead of silently shipping an empty blog.

Production checklist

  • Keep private keys out of PUBLIC_*, generated HTML, build logs, and client scripts.
  • Use lean fields for index pages and fetch full content by slug.
  • Follow cursors until has_more is false when generating all routes.
  • Sanitize Markdown before using set:html.
  • Map SEO, canonical, robots, and social fields into the document head.
  • Trigger a build or revalidation after every relevant editorial change.
  • Verify sitemap, feed, and robots output on the deployed origin.
  • Surface build and runtime failures to monitoring instead of publishing empty content.

On this page