$ cli-blog docs
Examples & Guides

Add a blog to Next.js

Build a production-ready Next.js blog with published delivery, Markdown pages, drafts, SEO metadata, sitemap, and RSS.

This tutorial adds a complete Cli Blog-powered blog to Next.js. Readers get statically cached pages; your server remains the only place that can create drafts.

What you will build

  • /blog lists published posts with cursor pagination.
  • /blog/[slug] renders one Markdown post and its SEO metadata.
  • /sitemap.xml and /feed.xml expose search and subscription files.
  • A trusted server action can create a draft without leaking a private key.

The examples use App Router first. A Pages Router version follows.

Prerequisites

You need a Next.js project, Node.js 20 or newer, and a Cli Blog organization with public and private API keys. Create at least one published post before testing the reader-facing routes.

Install the dependencies

npm install @cli-blog/node react-markdown

Configure API keys

Add both keys to .env.local:

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

Do not prefix either key with NEXT_PUBLIC_. Public keys may read published content, but keeping all data access on the server produces a simpler boundary. Private keys must never enter browser code, logs, or committed files.

Create server-only clients

Create lib/blog.ts:

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

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

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

Use publicBlog for delivery and adminBlog only for authenticated writes.

App Router

List published posts

Create app/blog/page.tsx:

import Link from "next/link";
import { publicBlog } from "@/lib/blog";

export const revalidate = 300;

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

  return (
    <main>
      <h1>Blog</h1>
      {posts.data.map((post) => (
        <article key={post.id}>
          <h2><Link href={`/blog/${post.slug}`}>{post.title}</Link></h2>
          {post.excerpt && <p>{post.excerpt}</p>}
        </article>
      ))}
    </main>
  );
}

summary adds list-friendly fields. seo adds metadata fields. Includes embed related objects so the page does not need separate author or tag requests.

Continue with cursor pagination

The default list mode is cursor-based. When has_more is true, request the next batch with the returned opaque cursor:

const next = await publicBlog.posts.list({
  status: "published",
  locale: "en-US",
  fields: ["summary"],
  limit: 20,
  after: first.next_cursor!,
});

Keep the same filters and sort for every request. The maximum limit is 100. Prefer cursors for load-more interfaces and synchronization; numbered pages do extra total-count work.

Render a post by slug

Create app/blog/[slug]/page.tsx:

import Markdown from "react-markdown";
import { publicBlog } from "@/lib/blog";

export const revalidate = 300;

export default async function PostPage({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const post = await publicBlog.posts.get(slug, {
    locale: "en-US",
    fields: ["summary", "content", "seo"],
    include: ["authors", "categories", "tags", "media"],
  });

  return <article><h1>{post.title}</h1><Markdown>{post.body_markdown}</Markdown></article>;
}

react-markdown does not enable raw HTML by default. Keep that default for untrusted author input. If you deliberately add HTML support, sanitize it with a maintained allowlist before rendering.

Generate page metadata

Export generateMetadata from the same page:

export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params;
  const post = await publicBlog.posts.get(slug, {
    locale: "en-US",
    fields: ["summary", "seo"],
  });

  return {
    title: post.seo_title ?? post.title,
    description: post.seo_description ?? post.excerpt,
    alternates: post.canonical_url ? { canonical: post.canonical_url } : undefined,
  };
}

Create a draft from trusted code

Keep authorization in your application as well as Cli Blog. This server action expects its caller to be authenticated:

"use server";

import { adminBlog } from "@/lib/blog";

export async function createDraft(input: { title: string; markdown: string }) {
  // Check the current application user before this call.
  const draft = await adminBlog.posts.create({
    title: input.title,
    body_markdown: input.markdown,
    locale: "en-US",
    status: "draft",
  });
  return { id: draft.id, status: draft.status };
}

Do not accept a requested published status from an untrusted form. Create a draft, validate the input, and make publishing a separate reviewed action. After that approved action changes the status, call revalidatePath("/blog") and revalidate the affected slug.

Add sitemap and feed routes

Create app/sitemap.xml/route.ts and use publicBlog.sitemap.get({ locale: "en-US", limit: 100 }). Return the XML with content-type: application/xml; charset=utf-8. Create app/feed.xml/route.ts the same way with publicBlog.feed.get({ locale: "en-US", limit: 20 }) and application/rss+xml; charset=utf-8.

Add a robots metadata route that points crawlers at https://example.com/sitemap.xml.

Pages Router

Use the same server-only public client from getStaticProps. Set revalidate: 300 for incremental static regeneration. In getStaticPaths, list published posts with fields: ["summary"], map slugs to paths, and use fallback: "blocking" so posts beyond the first cursor can still render. Fetch the full post in pages/blog/[slug].tsx with publicBlog.posts.get().

Place trusted draft creation in pages/api/, authenticate the request, and use adminBlog there. Never import the private client into a component or getInitialProps.

Handle errors

Return a 404 page for missing slugs. Treat 401 as a missing or invalid key, 403 as the wrong key type or permission, 409 as a version conflict, and 429 as a signal to retry with bounded exponential backoff. Log status, request context, and a safe error message—not API keys or complete request headers.

Production checklist

  • Keep private keys in your hosting provider's encrypted server environment.
  • Cache published reads and revalidate after your approved publish workflow.
  • Use summary for lists and request content only on detail pages.
  • Use cursor pagination and preserve its filters between requests.
  • Render Markdown without raw HTML, or sanitize with an explicit allowlist.
  • Set canonical metadata, expose sitemap and feed routes, and test their XML content types.
  • Add loading, empty, 404, and rate-limit states before launch.

On this page