Редакторы пишут статьи в CRM, ваш сайт их показывает. API блога отдаёт список по страницам — для ленты — и статью целиком по её адресу — для страницы статьи. Два запроса, и просьбы «опубликуй это к пятнице» больше не приходят вам в мессенджер.
Запроса два — по одному на каждый тип страницы, который вы будете собирать:
GET /v1/blog— лента: заголовки, обложки, авторы и даты, порциями. Текстов статей тут нет, поэтому ответ лёгкий.GET /v1/blog/:slug— одна статья со всем содержимым, включая HTML-текст.
Список статей блога
/v1/bloghttps://back.sitecog.com/content/v1/blog
Параметры запроса
langquerystringнеобязательноПо умолчанию: все активные языкиtitle и image. Один код, список через запятую (en,de) или повторённый параметр. Подробно — в разделе Языки.limitquerynumberнеобязательноПо умолчанию: размер страницы блога из CRM, иначе 12offsetquerynumberнеобязательноПо умолчанию: 0limit — для бесконечной ленты и кнопки «Показать ещё».pagequerynumberнеобязательноoffset для пагинации с номерами. Если пришли оба, побеждает page.perPagequerynumberнеобязательноПо умолчанию: как у limitlimit (1–50). Если пришли оба, побеждает perPage.x-crm-keyheaderstringобязательно?key=. Подробнее — в разделе Ключ сайта.Пример запроса
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} статей на ${blog.pages} страницах`);
blog.posts.forEach((post) => console.log(post.slug, post.title.en));Пример ответа
{
"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" }
}
]
}Поля ответа
totalnumberlimitnumberoffsetnumberpagenumber | nullnull, если offset не делится на limit (скажем, limit=10&offset=5) — честного номера страницы у такого окна нет.pagesnumberceil(total / limit). У пустого блога — 0.hasMorebooleantrue.nextOffsetnumber | nulloffset для следующего окна или null, если вы дошли до конца. Передавайте его в следующий запрос как есть.postsPost[]posts →
slugstringhow-we-chose-hosting. Подставляйте его в свои URL и в GET /v1/blog/:slug.authorstring | nullnull, если статью никто не подписал.publishedAtstring (ISO 8601) | nulltitle{ [lang]: string }image{ [lang]: string }Пагинация: бесконечная лента или страницы с номерами
API говорит на обоих диалектах пагинации, так что переводить один в другой в уме не придётся. Берите тот, что подходит вашему дизайну.
Бесконечная лента и «Показать ещё»: limit + offset
Запросите первое окно, покажите его, а когда посетитель долистает вниз (или нажмёт кнопку) — запросите следующее, начиная с nextOffset. Пришёл null — лента закончилась.
# первое окно
curl "https://back.sitecog.com/content/v1/blog?lang=en&limit=12" \
-H "x-crm-key: pk_3f9c2a7e1b4d4c0e8a6f5b2d9c1e7a40"
# следующее окно: offset = nextOffset из прошлого ответа
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; // конец ленты, грузить больше нечего
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); // ваша функция, которая дорисовывает карточки
nextOffset = data.nextOffset; // null, когда статьи закончились
loadMoreButton.hidden = !data.hasMore;
}
loadMoreButton.addEventListener('click', loadMore);
loadMore();Страницы с номерами: page + perPage
Классика «1 2 3 … 15». Отправляете номер страницы, получаете pages, чтобы нарисовать ссылки. Вот третья страница по 10 статей из 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();
// ссылки 1..pages, текущая подсвечена
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": "…" } }
]
}Обратите внимание: ответ всегда описывает окно на обоих диалектах сразу — page и pages для ссылок с номерами, limit, offset и nextOffset для ленты.
Если встретились оба способа
Отправили limit и perPage вместе? Побеждает perPage. offset и page? Побеждает page. Ошибки не будет ни в одном случае — но сделайте себе одолжение и держитесь одного способа в запросе.
Какие статьи видны и в каком порядке
API показывает ровно то, что должен увидеть посетитель, и ничего из того, над чем редактор ещё работает:
- Только опубликованные статьи. Черновики из CRM не выходят, какие параметры ни передавай.
- Отложенные ждут своего часа. Если в CRM включена публикация по расписанию, статья с датой в будущем не видна до этого момента. Из-за кэша она может появиться на несколько минут позже (до пяти примерно) — учитывайте это, когда планируете анонс.
- Порядок задаётся в CRM. Решают настройки блога: ручной порядок (редакторы перетаскивают статьи мышкой), по дате публикации или по дате создания, по возрастанию или по убыванию. API отдаёт статьи именно так; параметра сортировки нет, и главный здесь — редактор.
Одна статья
/v1/blog/:slughttps://back.sitecog.com/content/v1/blog/:slug
body — сам текст в HTML.Параметры
slugpathstringобязательноhow-we-chose-hosting. Строчные латинские буквы, цифры и дефисы, до 120 символов. Всё остальное даёт 404 post_not_found.langquerystringнеобязательноПо умолчанию: все активные языкиx-crm-keyheaderstringобязательно?key=, если заголовки передать нельзя.Пример запроса
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) {
// такой статьи нет или она ещё не опубликована — показываем свою страницу 404
}
const { post } = await res.json();
console.log(post.title.de); // "Wie wir Hosting gewählt haben"Пример ответа
{
"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>"
}
}
}Заметили, что body.de нет? Немецкий текст ещё не написан, а пустые переводы в картах блога не приходят вовсе — вместо "" ключа просто нет. Единственная обложка, наоборот, повторилась для обоих языков.
Поля ответа
postobjectpost →
slugstringauthorstring | nullpublishedAtstring (ISO 8601) | nullnull. Для людей её форматирует new Date(post.publishedAt).toLocaleDateString(lang).title{ [lang]: string }image{ [lang]: string }body{ [lang]: string }Как безопасно вывести HTML статьи
body — готовый HTML: заголовки, абзацы, списки, ссылки, картинки. Чтобы вставить его на страницу, понадобится переключатель вашего фреймворка «да, я правда хочу сырой HTML» — dangerouslySetInnerHTML в React, v-html во Vue.
Запасного языка на стороне сервера нет: если перевода нет, ключа просто не будет. Выручит крошечный помощник — сначала нужный язык, потом ваш основной, потом любой непустой:
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 очищен в Diil при сохранении статьи */}
<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 очищен в Diil при сохранении статьи -->
<div class="prose" v-html="body" />
</article>
</template>Блог на Next.js: лента и страница статьи
Полноценный блог на Next.js с headless CMS в трёх файлах: небольшой слой данных, лента со страницами и страница статьи, заранее собранная для каждого адреса через generateStaticParams. Ключ остаётся на сервере.
// lib/blog.ts — всё про блог в одном месте
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 не больше 50, поэтому обходим весь блог по 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 — лента со страницами по номерам
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>Статей пока нет. Авторы ещё варят кофе.</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 — одна статья, собрана заранее для каждого адреса
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) {
// тот же запрос, что и в странице, — Next.js не выполнит его дважды
const post = await getPost((await params).slug);
return { title: post ? t(post.title) : 'Статья не найдена' };
}
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>Автор: {post.author}</p>}
<div className="prose" dangerouslySetInnerHTML={{ __html: t(post.body) }} />
</article>
);
}Ошибки
| Статус | Тело ответа | Что случилось |
|---|---|---|
| 400 | {"message":"invalid_lang", …} | Код в lang не похож на код языка. |
| 400 | {"message":"unknown_lang", …} | Языка из lang нет среди активных языков сайта. Доступные перечислены прямо в ответе. |
| 400 | {"message":"too_many_langs","max":50} | В lang больше 50 кодов. Впечатляет, но нет. |
| 401 | {"message":"invalid_key"} | Ключа нет, он кривой или отозван. |
| 404 | {"message":"post_not_found"} | Видимой статьи с таким адресом нет: опечатка, запрещённые символы в адресе или статья ещё черновик либо не опубликована. |
| 405 | {"message":"method_not_allowed"} | Любой метод, кроме GET и HEAD. API только читает. |
| 429 | {"message":"rate_limit_exceeded"} | Слишком много запросов за эту минуту. Смотрите Лимиты. |
Кривых параметров пагинации в списке нет намеренно: вместо ошибки они превращаются в значения по умолчанию. Всё остальное — на странице Ошибки.