Deine Redakteure schreiben Posts im CRM, deine Website zeigt sie an. Die Blog-API liefert dir eine paginierte Liste für die Übersichtsseite und den kompletten Post per Slug für die Artikelseite — zwei Endpoints, und die „Kannst du das bis Freitag online stellen?“-Anfragen landen nicht mehr in deinem Postfach.
Zwei Endpoints, einer für jede Art von Seite, die du baust:
GET /v1/blog— die Liste: Titel, Cover, Autoren und Daten, Seite für Seite. Ohne Post-Texte, also schön leichtgewichtig.GET /v1/blog/:slug— ein Post mit allem Drum und Dran, inklusive HTML-Text.
Blog-Posts auflisten
/v1/bloghttps://back.sitecog.com/content/v1/blog
Query-Parameter
langquerystringoptionalStandard: alle aktiven Sprachentitle und image landen. Ein Code, eine kommagetrennte Liste (en,de) oder ein wiederholter Parameter. Siehe Sprachen.limitquerynumberoptionalStandard: Blog-Seitengröße aus dem CRM, sonst 12offsetquerynumberoptionalStandard: 0limit für Infinite Scroll und „Mehr laden“-Buttons.pagequerynumberoptionaloffset für nummerierte Pagination. Sind beide da, gewinnt page.perPagequerynumberoptionalStandard: wie limitlimit (1–50). Sind beide da, gewinnt perPage.x-crm-keyheaderstringPflicht?key=. Siehe Site-Keys.Beispiel-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 auf ${blog.pages} Seiten`);
blog.posts.forEach((post) => console.log(post.slug, post.title.en));Beispiel-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" }
}
]
}Felder der Response
totalnumberlimitnumberoffsetnumberpagenumber | nullnull, wenn offset kein Vielfaches von limit ist (etwa limit=10&offset=5) — dafür gibt es schlicht keine ehrliche Seitennummer.pagesnumberceil(total / limit). 0 bei einem leeren Blog.hasMorebooleantrue ist.nextOffsetnumber | nulloffset für das nächste Fenster oder null, wenn du am Ende bist. Einfach direkt in den nächsten Request stecken.postsPost[]posts →
slugstringhow-we-chose-hosting. Nutze sie in deinen URLs und für GET /v1/blog/:slug.authorstring | nullnull, wenn niemand den Post unterschrieben hat.publishedAtstring (ISO 8601) | nulltitle{ [lang]: string }image{ [lang]: string }Pagination: Infinite Scroll oder nummerierte Seiten
Die API spricht beide Pagination-Dialekte, du musst also nicht im Kopf zwischen ihnen übersetzen. Nimm einfach den, der zu deinem Design passt.
Infinite Scroll und „Mehr laden“: limit + offset
Hol das erste Fenster, zeig es an, und wenn der Besucher nach unten scrollt (oder auf den Button klickt), hol das nächste ab nextOffset. Ist nextOffset gleich null, bist du fertig.
# erstes Fenster
curl "https://back.sitecog.com/content/v1/blog?lang=en&limit=12" \
-H "x-crm-key: pk_3f9c2a7e1b4d4c0e8a6f5b2d9c1e7a40"
# nächstes Fenster: offset = nextOffset aus der vorherigen 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; // Ende, nichts mehr zu laden
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); // deine Funktion, die Karten anhängt
nextOffset = data.nextOffset; // null, wenn nichts mehr übrig ist
loadMoreButton.hidden = !data.hasMore;
}
loadMoreButton.addEventListener('click', loadMore);
loadMore();Nummerierte Seiten: page + perPage
Klassische „1 2 3 … 15“-Pagination. Schick die Seitennummer mit und bekomm pages zurück, um die Links zu zeichnen. Hier Seite 3 bei 10 Posts pro Seite, von insgesamt 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, der aktuelle hervorgehoben
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": "…" } }
]
}Beachte: Die Response beschreibt das Fenster immer in beiden Dialekten — page und pages für nummerierte Links, limit, offset und nextOffset fürs Scrollen.
Wenn beide Stile aufeinandertreffen
limit und perPage zusammen geschickt? perPage gewinnt. offset und page? page gewinnt. Einen Fehler gibt es in keinem Fall — aber tu dir selbst einen Gefallen und bleib pro Request bei einem Stil.
Welche Posts sichtbar sind und in welcher Reihenfolge
Die API zeigt genau das, was ein Besucher sehen soll, und nichts, woran die Redaktion noch arbeitet:
- Nur veröffentlichte Posts. Entwürfe verlassen das CRM nie, egal welche Parameter du schickst.
- Geplante Posts warten, bis sie dran sind. Ist die Zeitplanung im CRM aktiviert, bleibt ein Post mit einem Veröffentlichungsdatum in der Zukunft bis zu diesem Moment unsichtbar. Wegen des Cachings kann er ein paar Minuten später auftauchen (bis zu etwa fünf) — plan deine Launches mit diesem Puffer.
- Die Reihenfolge legt das CRM fest. Das entscheiden die Blog-Einstellungen: manuelle Reihenfolge (die Redaktion zieht Posts per Drag & Drop), nach Veröffentlichungs- oder Erstellungsdatum, auf- oder absteigend. Die API liefert die Posts in genau dieser Reihenfolge; einen Sortierparameter gibt es nicht, die Redaktion behält also das Ruder.
Einen einzelnen Post abrufen
/v1/blog/:slughttps://back.sitecog.com/content/v1/blog/:slug
body, der eigentliche Artikel als HTML.Parameter
slugpathstringPflichthow-we-chose-hosting. Lateinische Kleinbuchstaben, Ziffern und Bindestriche, bis zu 120 Zeichen. Alles andere ergibt 404 post_not_found.langquerystringoptionalStandard: alle aktiven Sprachentitle, image und body landen. Dieselben Regeln wie überall — siehe Sprachen.x-crm-keyheaderstringPflicht?key=, falls Header keine Option sind.Beispiel-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) {
// so einen Post gibt es nicht, oder er ist noch nicht veröffentlicht — zeig deine 404-Seite
}
const { post } = await res.json();
console.log(post.title.de); // "Wie wir Hosting gewählt haben"Beispiel-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>"
}
}
}Siehst du, dass body.de fehlt? Der deutsche Text ist noch nicht geschrieben, und leere Übersetzungen werden in Blog-Maps weggelassen, statt als "" zurückzukommen. Das einzige Cover dagegen wird für beide Sprachen wiederholt.
Felder der Response
postobjectpost →
slugstringauthorstring | nullpublishedAtstring (ISO 8601) | nullnull. Menschenlesbar formatierst du es mit new Date(post.publishedAt).toLocaleDateString(lang).title{ [lang]: string }image{ [lang]: string }body{ [lang]: string }Den HTML-Text sicher rendern
body ist fertiges HTML: Überschriften, Absätze, Listen, Links, Bilder. Um es auf die Seite zu bringen, brauchst du den „Ja, ich will wirklich rohes HTML“-Schalter deines Frameworks — dangerouslySetInnerHTML in React, v-html in Vue.
Eine Fallback-Sprache auf dem Server gibt es nicht: Fehlt eine Übersetzung, ist der Key einfach nicht da. Ein winziger Helper regelt das — erst die angefragte Sprache, dann deine Standardsprache, dann irgendwas, das nicht leer ist:
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 wird von Diil beim Speichern des Posts bereinigt */}
<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 wird von Diil beim Speichern des Posts bereinigt -->
<div class="prose" v-html="body" />
</article>
</template>Einen Blog in Next.js bauen: Übersicht und Post-Seiten
Ein kompletter Next.js-Blog mit Headless CMS in drei Dateien: eine kleine Datenschicht, die Übersicht mit nummerierten Seiten und die Post-Seite, für jeden Slug vorgerendert mit generateStaticParams. Der Key bleibt auf dem Server.
// lib/blog.ts — alles rund um den Blog an einem Ort
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 ist bei 50 gedeckelt, also den ganzen Blog per nextOffset durchlaufen
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 — die Übersicht mit nummerierten Seiten
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>Noch keine Posts. Die Autoren kochen noch Kaffee.</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 — ein Post, für jeden Slug vorgerendert
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) {
// derselbe fetch wie in der Seite — Next.js dedupliziert ihn
const post = await getPost((await params).slug);
return { title: post ? t(post.title) : 'Post nicht gefunden' };
}
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>Von {post.author}</p>}
<div className="prose" dangerouslySetInnerHTML={{ __html: t(post.body) }} />
</article>
);
}Fehler
| Status | Body | Was passiert ist |
|---|---|---|
| 400 | {"message":"invalid_lang", …} | Ein Code in lang sieht nicht wie ein Sprachcode aus. |
| 400 | {"message":"unknown_lang", …} | Eine Sprache in lang ist auf der Website nicht aktiv. Der Body listet die verfügbaren auf. |
| 400 | {"message":"too_many_langs","max":50} | Mehr als 50 Codes in lang. Beeindruckend, aber nein. |
| 401 | {"message":"invalid_key"} | Key fehlt, ist fehlerhaft oder wurde widerrufen. |
| 404 | {"message":"post_not_found"} | Kein sichtbarer Post mit diesem Slug — ein Tippfehler, ein Slug mit unerlaubten Zeichen oder ein Post, der ein Entwurf oder noch nicht veröffentlicht ist. |
| 405 | {"message":"method_not_allowed"} | Alles außer GET oder HEAD. Die API ist schreibgeschützt. |
| 429 | {"message":"rate_limit_exceeded"} | Zu viele Requests in dieser Minute. Siehe Rate Limits. |
Ungültige Pagination-Werte fehlen in dieser Liste mit Absicht: Sie fallen auf Standardwerte zurück, statt einen Fehler auszulösen. Alles andere findest du auf der Seite Fehler.