Diil Docs
  1. Doku
  2. API-Referenz

GET /v1/blog — Beiträge, Paginierung, einzelner Beitrag

Aktualisiert:

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

GET/v1/blog

https://back.sitecog.com/content/v1/blog

Liefert ein Fenster sichtbarer Posts plus alles, was du zum Paginieren brauchst: die Gesamtzahl, die Anzahl der Seiten, ob noch mehr kommt und wo das nächste Fenster beginnt.

Query-Parameter

langquerystringoptionalStandard: alle aktiven Sprachen
Welche Übersetzungen in title und image landen. Ein Code, eine kommagetrennte Liste (en,de) oder ein wiederholter Parameter. Siehe Sprachen.
limitquerynumberoptionalStandard: Blog-Seitengröße aus dem CRM, sonst 12
Wie viele Posts zurückkommen, von 1 bis 50. Hat die Redaktion in den Blog-Einstellungen im CRM eine Seitengröße festgelegt, ist das der Standard; wenn nicht, bekommst du 12.
offsetquerynumberoptionalStandard: 0
Wie viele Posts übersprungen werden, von 0 bis 100000. Kombiniere es mit limit für Infinite Scroll und „Mehr laden“-Buttons.
pagequerynumberoptional
Seitennummer, beginnend bei 1. Die Alternative zu offset für nummerierte Pagination. Sind beide da, gewinnt page.
perPagequerynumberoptionalStandard: wie limit
Posts pro Seite, mit denselben Grenzen wie limit (1–50). Sind beide da, gewinnt perPage.
x-crm-keyheaderstringPflicht
Dein Site-Key. Geht auch als ?key=. Siehe Site-Keys.

Beispiel-Request

curl "https://back.sitecog.com/content/v1/blog?lang=en&limit=2" \
  -H "x-crm-key: pk_3f9c2a7e1b4d4c0e8a6f5b2d9c1e7a40"

Beispiel-Response

200 OKjson
{
  "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

totalnumber
Wie viele Posts gerade sichtbar sind — Entwürfe und geplante Posts zählen nicht mit. Perfekt für „29 Artikel“ unter der Überschrift.
limitnumber
Die tatsächlich verwendete Fenstergröße, nachdem Standardwerte und Grenzen angewendet wurden.
offsetnumber
Wie viele Posts übersprungen wurden. Bei page/perPage wird das für dich berechnet.
pagenumber | null
Die aktuelle Seitennummer, beginnend bei 1. null, wenn offset kein Vielfaches von limit ist (etwa limit=10&offset=5) — dafür gibt es schlicht keine ehrliche Seitennummer.
pagesnumber
Gesamtzahl der Seiten: ceil(total / limit). 0 bei einem leeren Blog.
hasMoreboolean
Ob nach diesem Fenster noch Posts kommen. Dein „Mehr laden“-Button lebt genau so lange, wie das true ist.
nextOffsetnumber | null
Der offset für das nächste Fenster oder null, wenn du am Ende bist. Einfach direkt in den nächsten Request stecken.
postsPost[]
Die Posts dieses Fensters, in der im CRM festgelegten Reihenfolge. Ohne Texte — dafür holst du einen einzelnen Post.
posts →
slugstring
Die Adresse des Posts, z. B. how-we-chose-hosting. Nutze sie in deinen URLs und für GET /v1/blog/:slug.
authorstring | null
Der Autorenname, wie er im CRM eingetippt wurde, oder null, wenn niemand den Post unterschrieben hat.
publishedAtstring (ISO 8601) | null
Veröffentlichungsdatum oder null, wenn der Post keins hat.
title{ [lang]: string }
Titel des Posts pro Sprache. Leere Übersetzungen werden weggelassen, eine Sprache kann also einfach fehlen — siehe den Fallback-Helper weiter unten.
image{ [lang]: string }
Cover-URL pro Sprache. Ein Post mit nur einem Cover bekommt dieselbe URL für jede angefragte Sprache, du kannst also immer image[lang] lesen.

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"

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"
200 OKjson
{
  "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

GET/v1/blog/:slug

https://back.sitecog.com/content/v1/blog/:slug

Der komplette Post per Slug: alles aus der Liste plus body, der eigentliche Artikel als HTML.

Parameter

slugpathstringPflicht
Der Slug des Posts aus der Liste, z. B. how-we-chose-hosting. Lateinische Kleinbuchstaben, Ziffern und Bindestriche, bis zu 120 Zeichen. Alles andere ergibt 404 post_not_found.
langquerystringoptionalStandard: alle aktiven Sprachen
Welche Übersetzungen in title, image und body landen. Dieselben Regeln wie überall — siehe Sprachen.
x-crm-keyheaderstringPflicht
Dein Site-Key oder ?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"

Beispiel-Response

200 OKjson
{
  "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

postobject
Der Post selbst, verpackt in einem Key.
post →
slugstring
Die Adresse des Posts, genau die, nach der du gefragt hast.
authorstring | null
Autorenname aus dem CRM oder null.
publishedAtstring (ISO 8601) | null
Veröffentlichungsdatum oder null. Menschenlesbar formatierst du es mit new Date(post.publishedAt).toLocaleDateString(lang).
title{ [lang]: string }
Titel pro Sprache. Leere Übersetzungen werden weggelassen.
image{ [lang]: string }
Cover-URL pro Sprache; ein einzelnes Cover wird für jede angefragte Sprache wiederholt.
body{ [lang]: string }
Der Artikel als HTML-String, pro Sprache. Leere Übersetzungen werden weggelassen. Wie du ihn renderst, steht gleich darunter.

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>
  );
}

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;
}

Fehler

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

Tipps aus der Praxis