Your editors write posts in the CRM, your site shows them. The blog API gives you a paginated list for the index page and a full post by its slug for the article page — two endpoints, and the “can you publish this by Friday” requests stop landing in your inbox.
Two endpoints, one for each kind of page you will build:
GET /v1/blog— the list: titles, covers, authors and dates, page by page. No post bodies, so it stays light.GET /v1/blog/:slug— one post with everything, including the HTML body.
List blog posts
/v1/bloghttps://back.sitecog.com/content/v1/blog
Query parameters
langquerystringoptionalDefault: all active languagestitle and image. One code, a comma list (en,de) or a repeated parameter. See Languages.limitquerynumberoptionalDefault: blog page size from the CRM, otherwise 12offsetquerynumberoptionalDefault: 0limit for infinite scroll and “load more” buttons.pagequerynumberoptionaloffset for numbered pagination. If both are present, page wins.perPagequerynumberoptionalDefault: same as limitlimit (1–50). If both are present, perPage wins.x-crm-keyheaderstringrequired?key=. See Site keys.Example request
curl "https://back.sitecog.com/content/v1/blog?lang=en&limit=2" \
-H "x-crm-key: pk_3f9c2a7e1b4d4c0e8a6f5b2d9c1e7a40"const res = await fetch('https://back.sitecog.com/content/v1/blog?lang=en&limit=2', {
headers: { 'x-crm-key': 'pk_3f9c2a7e1b4d4c0e8a6f5b2d9c1e7a40' },
});
if (!res.ok) throw new Error(`Content API: ${res.status}`);
const blog = await res.json();
console.log(`${blog.total} posts on ${blog.pages} pages`);
blog.posts.forEach((post) => console.log(post.slug, post.title.en));Example response
{
"total": 29,
"limit": 2,
"offset": 0,
"page": 1,
"pages": 15,
"hasMore": true,
"nextOffset": 2,
"posts": [
{
"slug": "how-we-chose-hosting",
"author": "Anton Kravtsov",
"publishedAt": "2026-08-10T00:00:00.000Z",
"title": { "en": "How we chose hosting and got it wrong twice" },
"image": { "en": "https://cdn.example.com/storage/your-site/blog/hosting-cover.jpg" }
},
{
"slug": "noise-cancelling-explained",
"author": null,
"publishedAt": "2026-07-28T00:00:00.000Z",
"title": { "en": "Noise cancelling, explained without the physics lecture" },
"image": { "en": "https://cdn.example.com/storage/your-site/blog/anc-cover.jpg" }
}
]
}Response fields
totalnumberlimitnumberoffsetnumberpagenumber | nullnull when offset is not a multiple of limit (say, limit=10&offset=5) — there is no honest page number for that.pagesnumberceil(total / limit). 0 for an empty blog.hasMorebooleantrue.nextOffsetnumber | nulloffset for the next window, or null when you have reached the end. Pass it straight into the next request.postsPost[]posts →
slugstringhow-we-chose-hosting. Use it in your URLs and for GET /v1/blog/:slug.authorstring | nullnull if nobody signed the post.publishedAtstring (ISO 8601) | nulltitle{ [lang]: string }image{ [lang]: string }Pagination: infinite scroll or numbered pages
The API speaks both pagination dialects, so you don't have to translate one into the other in your head. Pick the one that matches your design.
Infinite scroll and “load more”: limit + offset
Ask for the first window, show it, and when the visitor scrolls down (or clicks the button), ask for the next one starting at nextOffset. When nextOffset is null, you are done.
# first window
curl "https://back.sitecog.com/content/v1/blog?lang=en&limit=12" \
-H "x-crm-key: pk_3f9c2a7e1b4d4c0e8a6f5b2d9c1e7a40"
# next window: offset = nextOffset from the previous response
curl "https://back.sitecog.com/content/v1/blog?lang=en&limit=12&offset=12" \
-H "x-crm-key: pk_3f9c2a7e1b4d4c0e8a6f5b2d9c1e7a40"const API = 'https://back.sitecog.com/content/v1';
const KEY = 'pk_3f9c2a7e1b4d4c0e8a6f5b2d9c1e7a40';
let nextOffset = 0;
async function loadMore() {
if (nextOffset === null) return; // the end, nothing left to load
const res = await fetch(`${API}/blog?lang=en&limit=12&offset=${nextOffset}`, {
headers: { 'x-crm-key': KEY },
});
const data = await res.json();
renderPosts(data.posts); // your function that appends cards
nextOffset = data.nextOffset; // null when there is nothing left
loadMoreButton.hidden = !data.hasMore;
}
loadMoreButton.addEventListener('click', loadMore);
loadMore();Numbered pages: page + perPage
Classic “1 2 3 … 15” pagination. Send the page number, get back pages to draw the links. Here is page 3 of 10 posts per page, out of 29:
curl "https://back.sitecog.com/content/v1/blog?lang=en&page=3&perPage=10" \
-H "x-crm-key: pk_3f9c2a7e1b4d4c0e8a6f5b2d9c1e7a40"const page = Number(new URLSearchParams(location.search).get('page')) || 1;
const res = await fetch(`https://back.sitecog.com/content/v1/blog?lang=en&page=${page}&perPage=10`, {
headers: { 'x-crm-key': 'pk_3f9c2a7e1b4d4c0e8a6f5b2d9c1e7a40' },
});
const data = await res.json();
// links 1..pages, the current one highlighted
const links = Array.from({ length: data.pages }, (_, i) => ({
href: `/blog?page=${i + 1}`,
current: i + 1 === data.page,
}));{
"total": 29,
"limit": 10,
"offset": 20,
"page": 3,
"pages": 3,
"hasMore": false,
"nextOffset": null,
"posts": [
{ "slug": "…", "author": "…", "publishedAt": "…", "title": { "en": "…" }, "image": { "en": "…" } }
]
}Notice that the response always reports the window in both dialects: page and pages for numbered links, limit, offset and nextOffset for scrolling.
When both styles meet
Sent limit and perPage together? perPage wins. Sent offset and page? page wins. No error either way — but do yourself a favour and stick to one style per request.
Which posts are visible and in what order
The API shows exactly what a visitor should see, and nothing an editor is still working on:
- Only published posts. Drafts never leave the CRM, no matter what parameters you send.
- Scheduled posts wait their turn. If scheduling is enabled in the CRM, a post with a future publication date stays hidden until that moment. Because of caching it can show up a few minutes late (up to about five) — schedule launches with that in mind.
- Order is set in the CRM. The blog settings decide: manual order (editors drag and drop posts), by publication date or by creation date, ascending or descending. The API returns posts in that order; there is no sort parameter, so the editor stays in charge.
Get one post
/v1/blog/:slughttps://back.sitecog.com/content/v1/blog/:slug
body, the article itself as HTML.Parameters
slugpathstringrequiredhow-we-chose-hosting. Lowercase Latin letters, digits and hyphens, up to 120 characters. Anything else gives 404 post_not_found.langquerystringoptionalDefault: all active languagesx-crm-keyheaderstringrequired?key= if headers are not an option.Example request
curl "https://back.sitecog.com/content/v1/blog/how-we-chose-hosting?lang=en,de" \
-H "x-crm-key: pk_3f9c2a7e1b4d4c0e8a6f5b2d9c1e7a40"const res = await fetch('https://back.sitecog.com/content/v1/blog/how-we-chose-hosting?lang=en,de', {
headers: { 'x-crm-key': 'pk_3f9c2a7e1b4d4c0e8a6f5b2d9c1e7a40' },
});
if (res.status === 404) {
// no such post, or it is not published yet — show your 404 page
}
const { post } = await res.json();
console.log(post.title.de); // "Wie wir Hosting gewählt haben"Example response
{
"post": {
"slug": "how-we-chose-hosting",
"author": "Anton Kravtsov",
"publishedAt": "2026-08-10T00:00:00.000Z",
"title": {
"en": "How we chose hosting and got it wrong twice",
"de": "Wie wir Hosting gewählt haben"
},
"image": {
"en": "https://cdn.example.com/storage/your-site/blog/hosting-cover.jpg",
"de": "https://cdn.example.com/storage/your-site/blog/hosting-cover.jpg"
},
"body": {
"en": "<p>Attempt one was the cheapest server we could find.</p><h2>What went wrong</h2><p>Everything, on a Friday night.</p>"
}
}
}See the missing body.de? The German body hasn't been written yet, and empty translations are left out of blog maps instead of coming back as "". The single cover, on the other hand, is repeated for both languages.
Response fields
postobjectpost →
slugstringauthorstring | nullpublishedAtstring (ISO 8601) | nullnull. Format it for humans with new Date(post.publishedAt).toLocaleDateString(lang).title{ [lang]: string }image{ [lang]: string }body{ [lang]: string }Rendering the HTML body safely
body is ready-made HTML: headings, paragraphs, lists, links, images. To put it on the page you need your framework's “yes, I really mean raw HTML” switch — dangerouslySetInnerHTML in React, v-html in Vue.
There is no server-side fallback language: if a translation is missing, the key is just not there. A tiny helper covers it — the requested language, then your default one, then whatever is not empty:
const t = (map, lang, fallback = 'en') =>
map?.[lang] || map?.[fallback] || Object.values(map ?? {})[0] || '';
export function Post({ post, lang }) {
return (
<article>
<h1>{t(post.title, lang)}</h1>
{/* body is sanitized by Diil when the post is saved */}
<div className="prose" dangerouslySetInnerHTML={{ __html: t(post.body, lang) }} />
</article>
);
}<script setup>
import { computed } from 'vue';
const props = defineProps({ post: Object, lang: String });
const t = (map, lang, fallback = 'en') =>
map?.[lang] || map?.[fallback] || Object.values(map ?? {})[0] || '';
const body = computed(() => t(props.post.body, props.lang));
</script>
<template>
<article>
<h1>{{ t(post.title, lang) }}</h1>
<!-- body is sanitized by Diil when the post is saved -->
<div class="prose" v-html="body" />
</article>
</template>Building a blog in Next.js: index and post pages
A complete Next.js headless CMS blog in three files: a small data layer, the index with numbered pages and the post page, pre-rendered for every slug with generateStaticParams. The key stays on the server.
// lib/blog.ts — everything blog-related in one place
const API = 'https://back.sitecog.com/content/v1';
const LANG = 'en';
const headers = { 'x-crm-key': process.env.CRM_KEY! };
export type LangMap = Record<string, string>;
export type Post = {
slug: string;
author: string | null;
publishedAt: string | null;
title: LangMap;
image: LangMap;
body?: LangMap;
};
export const t = (map: LangMap | undefined, lang = LANG, fallback = 'en') =>
map?.[lang] || map?.[fallback] || Object.values(map ?? {})[0] || '';
export async function getPosts(page = 1, perPage = 12) {
const res = await fetch(`${API}/blog?lang=${LANG}&page=${page}&perPage=${perPage}`, {
headers,
next: { revalidate: 60 },
});
if (!res.ok) throw new Error(`Content API: ${res.status}`);
return (await res.json()) as { total: number; page: number | null; pages: number; posts: Post[] };
}
export async function getPost(slug: string) {
const res = await fetch(`${API}/blog/${slug}?lang=${LANG}`, {
headers,
next: { revalidate: 60 },
});
if (res.status === 404) return null;
if (!res.ok) throw new Error(`Content API: ${res.status}`);
return ((await res.json()) as { post: Post }).post;
}
// limit tops out at 50, so walk the whole blog with nextOffset
export async function getAllSlugs() {
const slugs: string[] = [];
let offset: number | null = 0;
while (offset !== null) {
const res = await fetch(`${API}/blog?lang=${LANG}&limit=50&offset=${offset}`, { headers });
if (!res.ok) throw new Error(`Content API: ${res.status}`);
const data: { posts: Post[]; nextOffset: number | null } = await res.json();
slugs.push(...data.posts.map((post) => post.slug));
offset = data.nextOffset;
}
return slugs;
}// app/blog/page.tsx — the index with numbered pages
import Link from 'next/link';
import { getPosts, t } from '@/lib/blog';
export default async function Blog({ searchParams }: { searchParams: Promise<{ page?: string }> }) {
const page = Number((await searchParams).page) || 1;
const { posts, pages } = await getPosts(page);
if (!posts.length) return <p>No posts yet. The authors are still brewing coffee.</p>;
return (
<main>
{posts.map((post) => (
<article key={post.slug}>
{t(post.image) && <img src={t(post.image)} alt="" />}
<h2><Link href={`/blog/${post.slug}`}>{t(post.title)}</Link></h2>
{post.publishedAt && (
<time dateTime={post.publishedAt}>{new Date(post.publishedAt).toLocaleDateString('en')}</time>
)}
</article>
))}
<nav>
{Array.from({ length: pages }, (_, i) => (
<Link key={i} href={`/blog?page=${i + 1}`} aria-current={i + 1 === page ? 'page' : undefined}>
{i + 1}
</Link>
))}
</nav>
</main>
);
}// app/blog/[slug]/page.tsx — one post, pre-rendered for every slug
import { notFound } from 'next/navigation';
import { getAllSlugs, getPost, t } from '@/lib/blog';
type Props = { params: Promise<{ slug: string }> };
export async function generateStaticParams() {
const slugs = await getAllSlugs();
return slugs.map((slug) => ({ slug }));
}
export async function generateMetadata({ params }: Props) {
// the same fetch as in the page — Next.js deduplicates it
const post = await getPost((await params).slug);
return { title: post ? t(post.title) : 'Post not found' };
}
export default async function PostPage({ params }: Props) {
const post = await getPost((await params).slug);
if (!post) notFound();
return (
<article>
<h1>{t(post.title)}</h1>
{post.author && <p>By {post.author}</p>}
<div className="prose" dangerouslySetInnerHTML={{ __html: t(post.body) }} />
</article>
);
}Errors
| Status | Body | What happened |
|---|---|---|
| 400 | {"message":"invalid_lang", …} | A code in lang doesn't look like a language code. |
| 400 | {"message":"unknown_lang", …} | A language in lang is not active on the site. The body lists the available ones. |
| 400 | {"message":"too_many_langs","max":50} | More than 50 codes in lang. Impressive, but no. |
| 401 | {"message":"invalid_key"} | Missing, malformed or revoked key. |
| 404 | {"message":"post_not_found"} | No visible post with this slug — a typo, a slug with forbidden characters, or a post that is a draft or not published yet. |
| 405 | {"message":"method_not_allowed"} | Anything other than GET or HEAD. The API is read-only. |
| 429 | {"message":"rate_limit_exceeded"} | Too many requests this minute. See Rate limits. |
Bad pagination values are not on this list on purpose: they fall back to defaults instead of failing. Everything else is on the Errors page.