$ cli-blog docs
Examples & Guides

Add a blog to TypeScript and JavaScript

Create drafts and read published posts with the Node SDK or direct fetch.

Use @cli-blog/node in Node.js 20 or newer. Use the same HTTP API with native fetch in browsers, edge runtimes, and other JavaScript environments.

Choose a client and key

RuntimeClientKey
Node.js server, script, or buildNode SDKPublic for delivery, private for trusted writes
BrowserNative fetchPublic for published reads only
Edge or non-Node JavaScriptNative fetchPublic reads or a securely stored private key in trusted server code

Private keys must never enter a browser bundle, serialized page data, logs, or client-visible environment variables. A server proxy is often preferable even for public delivery reads because it centralizes caching and rotation.

Install the server dependencies

The examples use the Node SDK plus a Markdown parser and sanitizer:

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

Configure environment variables

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

Give the private key only the resource permissions its workflow needs. Read API keys for type, storage, and rotation guidance.

Create separate SDK clients

Keep delivery and publishing clients in separate modules so private access cannot be imported accidentally into frontend code.

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

export const publicBlog = new CliBlog({
  apiKey: process.env.CLI_BLOG_PUBLIC_KEY!,
});
import { CliBlog } from "@cli-blog/node";

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

The default base URL is https://api.cli-blog.com.

List published posts

Request only the fields and relationships needed by the index:

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

for (const post of page.data) {
  console.log(post.title, post.slug, post.excerpt);
}

summary adds list-card properties and seo adds search metadata. Includes embed related resources in the same response. See Posts for all field groups, includes, filters, and sort options.

Follow cursor pages

Pass next_cursor back as after with the same query:

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

For a complete export or static build, use the cursor iterator:

for await (const post of publicBlog.posts.paginate({
  status: "published",
  locale: "en-US",
  fields: ["summary"],
  limit: 100,
})) {
  console.log(post.slug);
}

Cursor pagination is faster and more stable than exact numbered pages. The maximum limit is 100. Do not combine after or limit with page or per_page. See Pagination.

Retrieve a slug page

const post = await publicBlog.posts.get("shipping-an-api-first-blog", {
  locale: "en-US",
  fields: ["summary", "content", "seo"],
  include: ["authors", "categories", "tags", "media"],
});

When retrieving by slug, pass the locale used by that route. Map an API 404 to your application’s not-found response.

Render Markdown safely

Parse and sanitize before inserting HTML into a template:

import { marked } from "marked";
import sanitizeHtml from "sanitize-html";

const parsed = await marked.parse(post.body_markdown ?? "");
const bodyHtml = sanitizeHtml(parsed, {
  allowedSchemes: ["http", "https", "mailto"],
});

Never insert body_markdown or unsanitized parser output with innerHTML. If your UI framework has a Markdown component that does not enable raw HTML, prefer that component and review every plugin you add.

Map SEO metadata

Use values from the seo field group:

const metadata = {
  title: post.seo_title ?? post.title,
  description: post.seo_description ?? post.excerpt ?? "",
  canonical: post.canonical_url ?? undefined,
  robots: post.robots_index === false ? "noindex, nofollow" : "index, follow",
};

Also map Open Graph and Twitter overrides when your framework supports them. Generate metadata on the server when search crawlers or social previews need it in the initial HTML.

Read with native fetch

The HTTP API works without the SDK:

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");

const response = await fetch(url, {
  headers: { "x-api-key": publicKey },
});

if (!response.ok) throw new Error(`Cli Blog returned ${response.status}`);
const posts = await response.json();

In a browser, publicKey may be a public delivery key. Never substitute a private key. For a server proxy, keep even the public key in the server environment.

Create a trusted draft

Create drafts from a protected server route, CI job, or trusted agent process:

const draft = await adminBlog.posts.create({
  title: "Release notes",
  body_markdown: "## What changed\n\nA reviewed update.",
  locale: "en-US",
  status: "draft",
});

Web routes need your own session, role, CSRF, schema validation, input limits, and rate limiting before this call. Do not forward arbitrary request bodies. Keep publishing as a separate, reviewed status update.

Add sitemap and feed routes

Use the SDK’s XML resources in server handlers:

const sitemapXml = await publicBlog.sitemap.get({ locale: "en-US", limit: 100 });
const feedXml = await publicBlog.feed.get({ locale: "en-US", limit: 100 });

Return sitemap XML with application/xml; charset=utf-8 and feed XML with application/rss+xml; charset=utf-8. Link the sitemap from robots.txt and the feed from the document head.

Handle errors

The SDK throws CliBlogError with status, code, parameter, request ID, and response context:

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

try {
  return await publicBlog.posts.get(slug, { locale: "en-US" });
} catch (error) {
  if (error instanceof CliBlogError && error.status === 404) return null;
  if (error instanceof CliBlogError) {
    console.error({ status: error.status, code: error.code, requestId: error.requestId });
  }
  throw error;
}

Do not log keys, authorization headers, or full request objects. Honor Retry-After for 429; the SDK retries eligible read failures but does not retry writes automatically.

Cache and revalidate

Cache successful published reads at the server or CDN with a short shared TTL and stale revalidation. Purge the index and affected slug after publish, schedule, archive, or slug changes. Keep private write responses uncached and do not cache temporary failures as empty content.

Production checklist

  • Keep public and private clients in separate modules.
  • Keep private keys out of browser builds, serialized data, logs, and traces.
  • Request lean list fields and full content only for slug pages.
  • Follow cursors with an unchanged query and a maximum limit of 100.
  • Sanitize parsed Markdown before inserting HTML.
  • Map SEO, canonical, robots, and social fields into the document head.
  • Revalidate caches after editorial state changes.
  • Verify error, retry, sitemap, feed, and not-found behavior in production.

On this page