Start free. Scale as your blog grows.Get started

Developer documentation

Next.js

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.

Start from the official starter

contioreach/nextjs-starter-contioreach

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.

Terminal
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 dev

The rest of this page builds the same integration from scratch, so you can add ContioReach to an application you already have.

1. Environment variables

.env.local
CMS_API_URL=https://cms-api.contioreach.com
CMS_API_KEY=your_api_key_here
REVALIDATION_SECRET=your_webhook_secret

Do 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.

2. The content client

One helper handles the base URL, the auth header, the error checks, and the caching. Everything else is query parameters.

lib/contioreach.js
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.

3. The listing page

A Server Component fetches on the server and renders HTML. There is no loading state to write and no API key in the bundle.

app/blog/page.js
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.

4. The article page

Pre-render every article at build time, then render the body HTML the API returns.

app/blog/[slug]/page.js
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.

5. Publish instantly with a webhook

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.

app/api/revalidate/route.js
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 });
}

Register it

  1. 1In your workspace, open Developer Settings → Webhook Integration
  2. 2Enter https://your-site.com/api/revalidate as the URL
  3. 3Generate a secret and save it as REVALIDATION_SECRET in your host's environment
  4. 4Publish a post and confirm it appears without a redeploy

Test it locally

Terminal
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.

6. Cover images

next/image refuses to optimize a remote host it does not know about. Allow the ContioReach media domain:

next.config.mjs
const nextConfig = {
  images: {
    remotePatterns: [{ protocol: "https", hostname: "contiocdn.com" }]
  }
};

export default nextConfig;

7. Deploy

  1. 1Add CMS_API_URL, CMS_API_KEY, and REVALIDATION_SECRET to your host's environment variables
  2. 2Deploy — Vercel, Netlify, Cloudflare, or any Node host
  3. 3Add the deployed /api/revalidate URL as your publish webhook

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.