Редактори пишуть дописи в CRM, ваш сайт їх показує. API блогу дає список зі сторінками для головної блогу і повний допис за його slug для сторінки статті — два ендпоінти, і прохання «а опублікуйте це до п’ятниці» більше не падають вам у пошту.
Два ендпоінти — по одному на кожен тип сторінки, яку ви збудуєте:
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. Коли 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, щоб намалювати посилання. Ось сторінка 3 по 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 у трьох файлах: невеликий шар даних, головна з нумерованими сторінками і сторінка допису, заздалегідь відрендерена для кожного slug через 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 — один допис, відрендерений заздалегідь для кожного 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) {
// той самий fetch, що й на сторінці, — 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} | Понад 50 кодів у lang. Вражає, але ні. |
| 401 | {"message":"invalid_key"} | Ключа немає, він некоректний або відкликаний. |
| 404 | {"message":"post_not_found"} | Немає видимого допису з таким slug — одруківка, slug із забороненими символами або допис, що є чернеткою чи ще не опублікований. |
| 405 | {"message":"method_not_allowed"} | Будь-що, крім GET чи HEAD. API лише для читання. |
| 429 | {"message":"rate_limit_exceeded"} | Забагато запитів за цю хвилину. Див. Ліміти запитів. |
Некоректних значень пагінації в цьому списку немає навмисно: замість помилки вони відкочуються до значень за замовчуванням. Усе інше — на сторінці Помилки.