Add a blog to React
Create posts through a server route, then render published Cli Blog content in React.
A React single-page app runs in the browser. Put Cli Blog behind a backend-for-frontend (BFF) so you can cache delivery responses, keep key handling in one place, and reserve private publishing keys for trusted code.
Plan the integration
Use this boundary:
| Code | Key | Responsibility |
|---|---|---|
| React bundle | None | Request your /api/blog/* routes and render content |
| Delivery route | Public key | Read published posts, sitemap, and feed |
| Trusted write route | Private key | Create drafts after your own authentication and authorization checks |
Public keys may be used for published reads in a browser, but a BFF makes caching and key rotation easier. Never put a private key in a VITE_*, REACT_APP_*, or other browser-exposed variable.
Install a Markdown renderer
react-markdown renders Markdown as React elements without using dangerouslySetInnerHTML.
npm install react-markdownConfigure server environment variables
Add keys to the environment used by your backend, not to the React app:
CLI_BLOG_PUBLIC_KEY=<public-api-key>
CLI_BLOG_PRIVATE_KEY=<private-api-key>Read API keys before deciding which permissions the private key receives.
Build the delivery client
Create one small browser client that talks only to your BFF:
export type PostSummary = {
id: string;
title: string;
slug: string;
excerpt: string | null;
published_at: string | null;
};
export type PostPage = {
data: PostSummary[];
has_more: boolean;
next_cursor: string | null;
};
export async function listPosts(after?: string): Promise<PostPage> {
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) {
const response = await fetch(`/api/blog/posts/${encodeURIComponent(slug)}`);
if (!response.ok) throw new Error(response.status === 404 ? "Post not found" : "Could not load post");
return response.json();
}Implement the server reads
Your BFF can forward the list request to Cli Blog. Ask for only the field groups and includes the page uses:
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 (requestUrl.searchParams.get("after")) {
url.searchParams.set("after", requestUrl.searchParams.get("after")!);
}
return fetch(url, {
headers: { "x-api-key": process.env.CLI_BLOG_PUBLIC_KEY! },
});summary adds list-card fields. seo adds metadata fields. authors and tags embed those related resources, avoiding follow-up requests. See Posts for every field group and include.
For a slug route, request the body as well:
const url = new URL(`https://api.cli-blog.com/v1/posts/${encodeURIComponent(slug)}`);
url.searchParams.set("locale", "en-US");
url.searchParams.set("fields", "summary,content,seo");
url.searchParams.set("include", "authors,categories,tags,media");Render the blog index
Use the cursor returned by the API for a stable “Load more” experience:
import { useEffect, useState } from "react";
import { listPosts, type PostSummary } from "./blog-api";
export function BlogIndex() {
const [posts, setPosts] = useState<PostSummary[]>([]);
const [cursor, setCursor] = useState<string | null>();
const [error, setError] = useState<string>();
async function load(after?: string) {
try {
const page = await listPosts(after);
setPosts((current) => (after ? [...current, ...page.data] : page.data));
setCursor(page.has_more ? page.next_cursor : null);
} catch (cause) {
setError(cause instanceof Error ? cause.message : "Could not load posts");
}
}
useEffect(() => void load(), []);
if (error) return <p role="alert">{error}</p>;
return <>
<div>{posts.map((post) => <article key={post.id}>
<a href={`/blog/${post.slug}`}><h2>{post.title}</h2></a>
{post.excerpt && <p>{post.excerpt}</p>}
</article>)}</div>
{cursor && <button type="button" onClick={() => void load(cursor)}>Load more</button>}
</>;
}Cursor pagination is the recommended mode for feeds and synchronization. Keep the same locale, filters, sort, fields, and includes when sending next_cursor as after. The maximum limit is 100. Read Pagination for numbered-page tradeoffs.
Render a slug page safely
Connect this component to the dynamic-segment API in your router:
import Markdown from "react-markdown";
export function BlogPost({ post }: { post: { title: string; body_markdown: string | null } }) {
return <article>
<h1>{post.title}</h1>
<Markdown skipHtml>{post.body_markdown ?? ""}</Markdown>
</article>;
}skipHtml prevents raw HTML inside Markdown from being rendered. Do not replace this with unsanitized dangerouslySetInnerHTML. If you add plugins that allow HTML, sanitize the resulting HTML and review links, images, and embedded content.
Add page metadata
Map seo_title, seo_description, canonical_url, and robots fields from the seo field group into the metadata API provided by your router or document-head library. Fall back to the post title and excerpt when optional SEO fields are empty.
Create drafts from trusted code
Protect this route with your app’s authentication, authorization, CSRF, validation, and rate limiting before using a private key:
const response = await fetch("https://api.cli-blog.com/v1/posts", {
method: "POST",
headers: {
"content-type": "application/json",
"x-api-key": process.env.CLI_BLOG_PRIVATE_KEY!,
},
body: JSON.stringify({
title: input.title,
body_markdown: input.markdown,
locale: "en-US",
status: "draft",
}),
});Draft-first workflows leave a review step before publication. Do not forward arbitrary request bodies or let browser users choose the organization key.
Cache and revalidate
Cache published list and detail responses at the BFF or CDN, not private write responses. Use short s-maxage plus stale-while-revalidate, and purge or revalidate a slug after publishing. Preserve error status codes; do not cache 401, 403, or temporary 5xx responses as successful empty lists.
Production checklist
- Keep private keys out of browser variables, source maps, logs, and error payloads.
- Request
summary,seofor lists and addcontentonly on detail pages. - Render Markdown with raw HTML disabled or a reviewed sanitizer.
- Use cursor pagination and preserve the complete query between pages.
- Provide loading, empty, error, and not-found states.
- Set canonical, description, robots, and social metadata from SEO fields.
- Revalidate cached pages after publishing or scheduling changes.
- Proxy sitemap and feed from server routes if the React host supports them.