Developer documentation
Build a blog with the App Router, Server Components, and on-demand revalidation.
Next.js is the stack ContioReach is most often paired with, and the one with an official starter. Server Components fetch content on the server, so your API key never reaches the browser.
A complete blog: pagination, category archives, a table of contents built from the article body, SEO metadata, JSON-LD, a sitemap, and a working revalidation webhook. Next.js 16, Tailwind CSS v4, MIT licensed.
npx degit contioreach/nextjs-starter-contioreach my-blog
cd my-blog
npm install
cp .env.example .env.local
# add your API key to .env.local, then:
npm run devThe rest of this page builds the same integration from scratch, so you can add ContioReach to an application you already have.
CMS_API_URL=https://cms-api.contioreach.com
CMS_API_KEY=your_api_key_here
REVALIDATION_SECRET=your_webhook_secretDo not prefix these with NEXT_PUBLIC_. That prefix is what tells Next.js to inline a value into the browser bundle, which would publish your API key to every visitor.
One helper handles the base URL, the auth header, the error checks, and the caching. Everything else is query parameters.
const BASE_URL = process.env.CMS_API_URL;
async function apiRequest(endpoint, tags = []) {
const response = await fetch(`${BASE_URL}${endpoint}`, {
headers: {
"Content-Type": "application/json",
"X-API-Key": process.env.CMS_API_KEY
},
// Explicit: since Next.js 15, fetch is NOT cached by default.
cache: "force-cache",
next: { revalidate: 3600, tags }
});
if (!response.ok) {
throw new Error(`API request failed: ${response.status}`);
}
const result = await response.json();
if (!result.success) {
throw new Error(result.error?.message || "API request failed");
}
return result;
}
export async function getPosts({ page = 1, limit = 12, category, tags, search } = {}) {
const params = new URLSearchParams({
page: String(page),
limit: String(limit),
minimal: "true"
});
if (category) params.set("category", category);
if (tags) params.set("tags", tags);
if (search) params.set("search", search);
return apiRequest(`/v1/blogs?${params}`, ["posts"]);
}
export async function getPostBySlug(slug) {
const params = new URLSearchParams({ slug, minimal: "false" });
const result = await apiRequest(`/v1/blogs?${params}`, ["posts", `post-${slug}`]);
// A slug lookup returns a single object, not an array.
return Array.isArray(result.data) ? result.data[0] : result.data;
}
export async function getCategories() {
return apiRequest("/v1/categories?limit=100", ["categories"]);
}The two cache tags matter. Tagging every request "posts", and each article "post-<slug>", is what lets the publish webhook in step 5 invalidate exactly the affected content instead of your whole site.
A Server Component fetches on the server and renders HTML. There is no loading state to write and no API key in the bundle.
import Link from "next/link";
import { getPosts } from "@/lib/contioreach";
export default async function BlogPage({ searchParams }) {
// searchParams is a promise in the App Router — await it.
const { page = "1" } = await searchParams;
const { data: posts, meta } = await getPosts({ page: Number(page) });
return (
<main>
<h1>Blog</h1>
<ul>
{posts.map((post) => (
<li key={post.id}>
<Link href={`/blog/${post.slug}`}>{post.title}</Link>
<p>{post.excerpt}</p>
<time dateTime={post.publishedAt}>
{new Date(post.publishedAt).toLocaleDateString()}
</time>
</li>
))}
</ul>
{meta.hasNextPage && (
<Link href={`/blog?page=${meta.page + 1}`}>Next page</Link>
)}
</main>
);
}Reading searchParams opts this route out of static rendering, but the fetch underneath is still cached, so pagination stays fast without hitting the API on every request.
Pre-render every article at build time, then render the body HTML the API returns.
import { notFound } from "next/navigation";
import { getPosts, getPostBySlug } from "@/lib/contioreach";
export async function generateStaticParams() {
const slugs = [];
let page = 1;
let hasNextPage = true;
// limit is capped at 100, so walk the pages rather than asking for more.
while (hasNextPage) {
const { data, meta } = await getPosts({ page, limit: 100 });
slugs.push(...data.map((post) => ({ slug: post.slug })));
hasNextPage = meta.hasNextPage;
page += 1;
}
return slugs;
}
export async function generateMetadata({ params }) {
const { slug } = await params;
const post = await getPostBySlug(slug);
if (!post) return {};
return {
title: post.title,
description: post.description,
openGraph: {
title: post.title,
description: post.description,
images: post.coverImage ? [post.coverImage] : [],
type: "article",
publishedTime: post.publishedAt,
modifiedTime: post.updatedAt
}
};
}
export default async function PostPage({ params }) {
const { slug } = await params;
const post = await getPostBySlug(slug);
if (!post) notFound();
return (
<article>
<h1>{post.title}</h1>
{post.readingTime && <span>{post.readingTime} min read</span>}
{post.authors?.[0] && <span>By {post.authors[0].name}</span>}
{/* content is trusted HTML from your own workspace. */}
<div dangerouslySetInnerHTML={{ __html: post.content }} />
</article>
);
}dangerouslySetInnerHTML is appropriate here because the HTML comes from your own ContioReach workspace, written by your own authors. Never pass it HTML from an untrusted source.
With revalidate: 3600, an author can wait an hour to see a post live. Point your ContioReach publish webhook at a route that clears the cache tags instead.
import { revalidatePath, revalidateTag } from "next/cache";
import { NextResponse } from "next/server";
export async function POST(request) {
const body = await request.json().catch(() => ({}));
const secret = body?.secret || request.headers.get("x-api-key");
if (secret !== process.env.REVALIDATION_SECRET) {
return NextResponse.json({ error: "Invalid token" }, { status: 401 });
}
// "max" means stale-while-revalidate: the next visitor is served the
// cached page immediately while the refresh happens behind them.
revalidateTag("posts", "max");
revalidateTag("categories", "max");
if (body?.post?.slug) {
revalidateTag(`post-${body.post.slug}`, "max");
}
// Tags invalidate data; revalidatePath invalidates rendered routes.
// A content site generally needs both.
revalidatePath("/blog");
revalidatePath("/blog/[slug]", "page");
return NextResponse.json({ success: true });
}curl -X POST http://localhost:3000/api/revalidate \
-H "x-api-key: $REVALIDATION_SECRET" \
-H "content-type: application/json" \
-d '{"post":{"slug":"hello-world"}}'An unknown secret returns 401, so the route is safe to leave publicly reachable — which it has to be for ContioReach to call it.
next/image refuses to optimize a remote host it does not know about. Allow the ContioReach media domain:
const nextConfig = {
images: {
remotePatterns: [{ protocol: "https", hostname: "contiocdn.com" }]
}
};
export default nextConfig;Cache tags and revalidateTag need a running Next.js server. On a fully static export (output: "export") there is nothing to invalidate at runtime, so trigger a rebuild from the webhook instead — the Gatsby guide describes that pattern.