$ cli-blog docs
Examples & Guides

Add a blog to Express

Build published post routes, Markdown pages, draft creation, pagination, caching, sitemap, and RSS with Express.

Express is a useful boundary for a blog: browsers request content from your app, while Cli Blog keys stay in the server process. This guide builds JSON delivery routes and shows how to render safe HTML when Express owns the page response.

Architecture

Use two clients with different responsibilities:

  • A public client lists and retrieves published posts.
  • A private client creates drafts after your own authentication and validation.
  • Express sets caching, error, and XML response behavior at the edge of your app.

Prerequisites

You need Node.js 20 or newer, an Express application, and a Cli Blog organization with public and private API keys.

Install dependencies

npm install express @cli-blog/node marked sanitize-html

marked converts Markdown to HTML. sanitize-html removes unsafe elements before a rendered page reaches a browser.

Configure environment variables

CLI_BLOG_PUBLIC_KEY=<public-api-key>
CLI_BLOG_API_KEY=<private-api-key>
SITE_URL=https://example.com

Load these values through your deployment platform. Do not commit .env files or expose the private value through a JSON route.

Create the clients

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

const app = express();
app.use(express.json({ limit: "256kb" }));

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

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

Fail application startup if required variables are missing. That is easier to diagnose than an authentication failure on the first request.

List published posts

app.get("/api/blog/posts", async (req, res, next) => {
  try {
    const result = await publicBlog.posts.list({
      status: "published",
      locale: "en-US",
      fields: ["summary", "seo"],
      include: ["authors", "tags"],
      limit: 20,
      after: typeof req.query.after === "string" ? req.query.after : undefined,
    });

    res.set("cache-control", "public, max-age=60, stale-while-revalidate=300");
    res.json(result);
  } catch (error) {
    next(error);
  }
});

Field groups control which post fields are returned. Use summary and seo for cards and previews. Includes embed authors and tags in the same response.

Follow the cursor

Return next_cursor to the browser unchanged. When has_more is true, send it back as ?after=<opaque-cursor>. Keep the locale, filters, and sort identical between calls. The maximum list limit is 100.

Cursor pagination is the preferred mode because it avoids exact total counts and remains stable for load-more flows. Use numbered pages only when the interface truly needs page numbers and exact totals.

Retrieve a post by slug

app.get("/api/blog/posts/:slug", async (req, res, next) => {
  try {
    const post = await publicBlog.posts.get(req.params.slug, {
      locale: "en-US",
      fields: ["summary", "content", "seo"],
      include: ["authors", "categories", "tags", "media"],
    });

    res.set("cache-control", "public, max-age=60, stale-while-revalidate=300");
    res.json(post);
  } catch (error) {
    next(error);
  }
});

Request content on detail routes, not list routes. Slug lookup is locale-aware, so keep the locale explicit when your site supports more than one language.

Render Markdown safely

If Express renders HTML, sanitize the Markdown output before placing it in a template:

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

const unsafeHtml = await marked.parse(post.body_markdown);
const articleHtml = sanitizeHtml(unsafeHtml, {
  allowedTags: sanitizeHtml.defaults.allowedTags.concat(["img"]),
  allowedAttributes: {
    a: ["href", "title"],
    img: ["src", "alt", "title", "width", "height", "loading"],
  },
});

Escape post.title, descriptions, and other plain-text values separately in your template engine. Do not concatenate unsanitized metadata into <head> tags.

Add SEO metadata

Use seo_title ?? title for the document title and seo_description ?? excerpt for the description. Honor a post's canonical URL when present. Add Open Graph fields from the seo field group and use included media for image metadata.

Create a trusted draft route

Authenticate and authorize this route before calling Cli Blog. The placeholder middleware represents your existing application policy:

app.post("/api/blog/drafts", requireEditor, async (req, res, next) => {
  try {
    const { title, markdown } = req.body;
    if (typeof title !== "string" || typeof markdown !== "string") {
      return res.status(400).json({ error: "title and markdown are required" });
    }

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

    res.status(201).json({ id: draft.id, status: draft.status });
  } catch (error) {
    next(error);
  }
});

Never let an untrusted request choose status: "published". Keep publishing behind a separate approval step.

Proxy sitemap and feed XML

app.get("/sitemap.xml", async (_req, res, next) => {
  try {
    const xml = await publicBlog.sitemap.get({ locale: "en-US", limit: 100 });
    res.type("application/xml").send(xml);
  } catch (error) { next(error); }
});

app.get("/feed.xml", async (_req, res, next) => {
  try {
    const xml = await publicBlog.feed.get({ locale: "en-US", limit: 20 });
    res.type("application/rss+xml").send(xml);
  } catch (error) { next(error); }
});

Serve these routes on the public blog domain and link the RSS feed from your page <head>.

Handle API errors

Add one error middleware after the routes. If an error is a CliBlogError, map 404 to a not-found response, pass 429 with a short retry hint, and return a generic message for server errors. A 401 usually means a missing or invalid key; 403 means the key type or permissions do not allow the operation.

Do not serialize the original request headers or API key into logs. For background work, retry transient 429 and 5xx responses with bounded exponential backoff.

Production checklist

  • Keep both keys in the server environment and rotate them independently.
  • Protect write routes with your app's authentication, authorization, validation, and rate limits.
  • Cache published reads; never publicly cache draft responses.
  • Request lean field groups on list routes and full content only for details.
  • Preserve cursor filters and treat cursors as opaque.
  • Sanitize rendered Markdown and escape all plain-text metadata.
  • Test empty lists, missing slugs, upstream timeouts, 429 responses, and malformed input.
  • Validate sitemap and feed content types from the deployed domain.

On this page