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
| Location | Key | Responsibility |
|---|---|---|
| Route component and loader | None | Request local blog data and render it |
| Delivery server function or API route | Public key | Read published posts |
| Protected server function or API route | Private key | Create 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-markdownIf 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/nodeimport { 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,seoon lists and addcontentonly 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.