$ cli-blog docs
Examples & Guides

Add a blog to TanStack

Create drafts and read published posts from TanStack server loaders and functions.

TanStack Router works well for a typed blog index and slug route. Put upstream reads in a backend-for-frontend (BFF) or TanStack Start server function, and keep trusted writes in protected server code.

Separate delivery from publishing

LocationKeyResponsibility
Route component and loaderNoneRequest local blog data and render it
Delivery server function or API routePublic keyRead published posts
Protected server function or API routePrivate keyCreate drafts after authorization

A public key may be used in browser code for published reads, but a server boundary makes caching and rotation easier. A private key must never enter route loader data, dehydrated state, or a client bundle.

Install the packages

This example uses TanStack Router and react-markdown:

npm install @tanstack/react-router react-markdown

If your TanStack application already has Router, install only the Markdown renderer.

Configure server environment variables

Store both values in the server environment:

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

Do not expose the private value through a client-prefixed variable. Use API keys to choose narrow permissions.

Define the delivery types

export type PostSummary = {
  id: string;
  title: string;
  slug: string;
  excerpt: string | null;
  published_at: string | null;
};

export type Post = PostSummary & {
  body_markdown: string | null;
  seo_title?: string | null;
  seo_description?: string | null;
  canonical_url?: string | null;
};

export type PostList = {
  data: PostSummary[];
  has_more: boolean;
  next_cursor: string | null;
};

Implement the delivery boundary

Whether you use a TanStack Start server function or another API server, construct the Cli Blog query on the server:

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");
if (after) url.searchParams.set("after", after);

const response = await fetch(url, {
  headers: { "x-api-key": process.env.CLI_BLOG_PUBLIC_KEY! },
});

Expose the result to the application as GET /api/blog/posts. Add GET /api/blog/posts/:slug for details, calling GET /v1/posts/{slug} with fields=summary,content,seo and include=authors,categories,tags,media.

Field groups add named sets of post properties. Includes embed related resources and avoid separate requests. Use lean summary,seo lists and add content only for a full post. See Posts.

Add client request helpers

export async function listPosts(after?: string): Promise<PostList> {
  const query = new URLSearchParams();
  if (after) query.set("after", after);
  const response = await fetch(`/api/blog/posts?${query}`);
  if (!response.ok) throw new Error("Could not load posts");
  return response.json();
}

export async function getPost(slug: string): Promise<Post> {
  const response = await fetch(`/api/blog/posts/${encodeURIComponent(slug)}`);
  if (response.status === 404) throw new Error("Post not found");
  if (!response.ok) throw new Error("Could not load post");
  return response.json();
}

For server rendering, call the same server-owned delivery helper directly instead of depending on a browser-relative URL.

Build the index route

In a file-based Router project, add the equivalent of routes/blog/index.tsx:

import { createFileRoute, Link } from "@tanstack/react-router";
import { useState } from "react";

export const Route = createFileRoute("/blog/")({
  loader: () => listPosts(),
  component: BlogIndex,
});

function BlogIndex() {
  const firstPage = Route.useLoaderData();
  const [posts, setPosts] = useState(firstPage.data);
  const [cursor, setCursor] = useState(firstPage.next_cursor);

  async function loadMore() {
    if (!cursor) return;
    const page = await listPosts(cursor);
    setPosts((current) => [...current, ...page.data]);
    setCursor(page.has_more ? page.next_cursor : null);
  }

  return <>
    {posts.map((post) => <article key={post.id}>
      <h2><Link to="/blog/$slug" params={{ slug: post.slug }}>{post.title}</Link></h2>
      {post.excerpt && <p>{post.excerpt}</p>}
    </article>)}
    {cursor && <button type="button" onClick={() => void loadMore()}>Load more</button>}
  </>;
}

Cursor pagination is the recommended mode for lists. Pass next_cursor back as after, and preserve the same locale, filters, sort, fields, and includes. The maximum limit is 100. Read Pagination for numbered-page tradeoffs.

Build the slug route

Add the equivalent of routes/blog/$slug.tsx:

import { createFileRoute } from "@tanstack/react-router";
import Markdown from "react-markdown";
import { useEffect } from "react";

export const Route = createFileRoute("/blog/$slug")({
  loader: ({ params }) => getPost(params.slug),
  component: BlogPost,
});

function BlogPost() {
  const post = Route.useLoaderData();

  useEffect(() => {
    document.title = post.seo_title ?? post.title;
  }, [post]);

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

skipHtml blocks raw HTML from Markdown. Do not replace it with unsanitized dangerouslySetInnerHTML. Configure the route’s not-found behavior for upstream 404 responses.

Add metadata and discovery

For an SSR application, map seo_title, seo_description, canonical_url, robots, and social values into the route-head API used by your installed TanStack Router or Start version. Avoid client-only document.title when metadata must be in the initial HTML.

Expose GET /v1/sitemap and GET /v1/feed through server routes. Return sitemap XML as application/xml and feed XML as application/rss+xml, then link both from the appropriate document and robots metadata.

Create drafts in trusted server code

The Node SDK is convenient in Node.js 20 or newer server functions.

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

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

export async function createDraft(input: { title: string; markdown: string }) {
  return adminBlog.posts.create({
    title: input.title,
    body_markdown: input.markdown,
    locale: "en-US",
    status: "draft",
  });
}

Call this only after your application verifies the session, role, CSRF token, payload, and rate limit. Do not return or serialize the client. Draft-first publishing keeps a review step before status changes.

Cache and revalidate

Use the BFF response cache for published content and your Router or Query cache for navigation reuse. Give server data a short shared TTL, revalidate the list and slug after publication, and keep private mutation responses uncached. Do not convert upstream failures into cacheable empty arrays.

Production checklist

  • Keep private keys out of loaders that serialize data, client variables, logs, and traces.
  • Use summary,seo on lists and add content only on detail routes.
  • Render Markdown with raw HTML disabled or a reviewed sanitizer.
  • Preserve cursor queries, deduplicate appended posts, and cap requests at 100.
  • Add pending, empty, error, retry, and not-found UI.
  • Render metadata on the server when SEO matters.
  • Revalidate cached routes after publish, schedule, archive, or slug changes.
  • Verify sitemap and feed routes on the deployed origin.

On this page