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
| Mode | Good fit | Update behavior |
|---|---|---|
| Static output | Documentation, product blogs, release notes | Rebuild after publication |
| Server output | Frequently changing or personalized sites | Cache 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-htmlConfigure 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_moreis 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.