Content-APIs zwingen dich meistens zur Wahl: schnell oder aktuell. Wir lassen das lieber. Responses werden bei uns gecacht und veralten dort trotzdem nie, jede Response trägt ein ETag, sodass unveränderter Content dich so gut wie nichts kostet, und eine Änderung im CRM ist in dem Moment in der API, in dem die Redaktion auf „Speichern“ klickt. Diese Seite erklärt, wie das funktioniert und wie du bei dir das Beste daraus machst.
Von der Änderung im CRM bis auf deine Seite
Hier die komplette Reise einer Änderung, von der Tastatur der Redaktion bis auf den Bildschirm deiner Besucher:
Die Redaktion speichert
Jemand korrigiert einen Tippfehler im CRM oder direkt auf der Live-Website. Jede Änderung zählt — ein Text, ein Bild, eine Seiteneinstellung, ein Blog-Post.Die Content-Version der Website steigt
Jede Änderung erhöht die Content-Version der Website. Unser serverseitiger Cache (Redis) hängt an dieser Version, also werden alle alten gecachten Antworten auf einen Schlag irrelevant. Niemand muss „den Cache leeren“.Der nächste API-Request bekommt frische Daten
Schon der allernächste Request an die API baut seine Response aus dem neuen Content. Auf unserer Seite gibt es null Verzögerung: keine TTL zum Aussitzen, keine Purge-Queue.Caches zwischen uns und dem Besucher ziehen nach
Was ein Besucher tatsächlich sieht, kann ein wenig hinterherhinken: Browser oder CDN dürfen die vorherige Response bis zu 60 Sekunden behalten und sie noch einmal ausliefern, während im Hintergrund leise die neue geholt wird (das iststale-while-revalidate). Dein eigener Server-Cache, falls du einen hast, legt seine Lebensdauer noch obendrauf.
Cache-Header erklärt
Eine typische erfolgreiche Response kommt mit diesen Headern:
HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: public, max-age=60, stale-while-revalidate=600
ETag: W/"a41f9c0e7b2d58f3"
Vary: x-crm-key, Accept-Encoding
Access-Control-Allow-Origin: *| Header | Was er Caches sagt |
|---|---|
Cache-Control: public | Jeder darf die Response speichern: Browser, CDN, Proxy. Der Content ist sowieso öffentlich — es ist genau das, was du auf deiner Website zeigst. |
max-age=60 | 60 Sekunden lang gilt die Response als frisch und kann wiederverwendet werden, ohne uns überhaupt zu fragen. |
stale-while-revalidate=600 | Die nächsten 10 Minuten darf ein Cache die alte Kopie sofort ausliefern und im Hintergrund eine neue holen. Schnell für diesen Besucher, frisch für den nächsten. |
ETag: W/"…" | Ein schwaches ETag — ein Hash des Response-Bodys. Gleicher Body, gleiches ETag. Schick es in If-None-Match zurück, und du bekommst 304, wenn sich nichts geändert hat. |
Vary: x-crm-key, Accept-Encoding | Caches müssen getrennte Kopien pro Site-Key und pro Komprimierung halten. Zwei Websites teilen sich nie eine gecachte Antwort, selbst bei derselben URL. |
Cache-Control: no-store | Kommt bei jedem Fehler mit. Ein 404 für eine Seite, die die Redaktion gerade anlegt, soll in niemandes Cache hängen bleiben. |
CORS ist für jeden Origin offen, die API erlaubt ausdrücklich den Request-Header If-None-Match und gibt ETag für JavaScript frei — alles auf dieser Seite funktioniert also auch im Browser.
ETag und 304 Not Modified
Das ETag ist der günstigste Weg zu fragen: „Hat sich was geändert?“. Merk dir das ETag aus der letzten Response, schick es beim nächsten Mal in If-None-Match mit, und wenn der Content gleich ist, bekommst du 304 Not Modified mit leerem Body. Dein Code nutzt einfach weiter die Kopie, die er schon hat.
GET /content/v1/pages/home?lang=en HTTP/1.1
Host: back.sitecog.com
x-crm-key: pk_3f9c2a7e1b4d4c0e8a6f5b2d9c1e7a40
HTTP/1.1 200 OK
Cache-Control: public, max-age=60, stale-while-revalidate=600
ETag: W/"a41f9c0e7b2d58f3"
Vary: x-crm-key, Accept-Encoding
{ "id": 26, "marker": "home", "name": "Home", "content": { … } }GET /content/v1/pages/home?lang=en HTTP/1.1
Host: back.sitecog.com
x-crm-key: pk_3f9c2a7e1b4d4c0e8a6f5b2d9c1e7a40
If-None-Match: W/"a41f9c0e7b2d58f3"
HTTP/1.1 304 Not Modified
Cache-Control: public, max-age=60, stale-while-revalidate=600
ETag: W/"a41f9c0e7b2d58f3"Sobald die Redaktion auf dieser Seite etwas ändert, ändert sich der Body, der Hash ändert sich, und derselbe Request liefert ein frisches 200 mit neuem ETag.
Caching-Rezepte
Im Browser: schon erledigt
Wenn du Content direkt im Browser holst, brauchst du keine einzige Zeile Caching-Code. Ein schlichtes fetch nutzt den HTTP-Cache des Browsers: 60 Sekunden lang verwendet es die Response wieder, danach revalidiert es ganz von selbst mit If-None-Match.
const res = await fetch('https://back.sitecog.com/content/v1/pages/home?lang=en', {
headers: { 'x-crm-key': 'pk_3f9c2a7e1b4d4c0e8a6f5b2d9c1e7a40' },
// cache: 'default' ist der Standard — max-age und ETag erledigt der Browser für dich
});
const page = await res.json();Wundere dich nicht, dass dein Code nie ein 304 zu sehen bekommt: Der Browser tauscht es gegen das gecachte 200 aus, bevor es bei dir ankommt. Öffne den Network-Tab in den DevTools, um zu sehen, was wirklich passiert ist — dort findest du das 304 oder „disk cache“.
Auf einem Node-Server: ein winziger ETag-Cache
Serverseitiges fetch in Node hat keinen eigenen HTTP-Cache, jeder Aufruf geht also den ganzen Weg bis zur API. Eine kleine Map löst das: Kopie 60 Sekunden lang wiederverwenden (genau wie max-age), dann mit If-None-Match nachfragen und den Body nur laden, wenn er sich geändert hat.
const API = 'https://back.sitecog.com/content';
const TTL = 60_000; // wie max-age=60
type Entry = { etag: string | null; data: unknown; at: number };
// Ein Prozess, ein Site-Key. Mehrere Keys? Dann gehört der Key mit in den Cache-Key.
const cache = new Map<string, Entry>();
export async function getContent<T>(path: string): Promise<T> {
const url = API + path;
const cached = cache.get(url);
// 1. Frisch genug — die API gar nicht erst aufrufen
if (cached && Date.now() - cached.at < TTL) return cached.data as T;
// 2. Mit dem ETag, das wir schon haben, fragen: "Hat sich was geändert?"
const headers: Record<string, string> = { 'x-crm-key': process.env.CRM_KEY! };
if (cached?.etag) headers['if-none-match'] = cached.etag;
const res = await fetch(url, { headers });
if (res.status === 304 && cached) {
cached.at = Date.now(); // gleicher Content, noch mal 60 Sekunden Ruhe
return cached.data as T;
}
if (!res.ok) throw new Error(`Content API ${res.status} for ${path}`);
const data = (await res.json()) as T;
cache.set(url, { etag: res.headers.get('etag'), data, at: Date.now() });
return data;
}
// Verwendung
const home = await getContent('/v1/pages/home?lang=en');Next.js: revalidate und Refresh auf Abruf
Im App Router erledigt der fetch-Cache die Arbeit für dich. revalidate: 60 passt zu unserem max-age: Next.js behält die Response eine Minute lang und aktualisiert sie dann im Hintergrund.
export default async function Home() {
const res = await fetch('https://back.sitecog.com/content/v1/pages/home?lang=en', {
headers: { 'x-crm-key': process.env.CRM_KEY! },
next: { revalidate: 60, tags: ['crm-content'] },
});
const page = await res.json();
return <h1>{page.content.hero.content.hero_title.content.en}</h1>;
}Weniger Verzögerung gewünscht? Senk revalidate — aber behalt die Limits im Auge. Du willst einen „Jetzt veröffentlichen“-Button für einen großen Launch? Versieh deine Fetches mit einem Tag und stell eine kleine Route bereit, die den Tag verwirft:
import { revalidateTag } from 'next/cache';
export async function POST(req: Request) {
if (req.headers.get('x-revalidate-secret') !== process.env.REVALIDATE_SECRET) {
return new Response('Nope', { status: 401 });
}
// Next.js 16 nimmt ein zweites Argument; in Next.js 15 ist es einfach revalidateTag('crm-content')
revalidateTag('crm-content', { expire: 0 });
return Response.json({ revalidated: true });
}Ruf sie auf, wo es zu deinem Team passt: aus einem Deploy-Skript, einem Bookmarklet, einem Chatbot, einem Button in deinem eigenen Admin-Panel. Das Secret gehört nur dir — mit dem Site-Key hat es nichts zu tun.
Live-Modus: jeden Cache umgehen
Wenn die Redaktion deine Website im Live-Modus des CRM öffnet, will sie ihre Änderung direkt nach dem Speichern sehen — nicht eine Minute später. Im CRM-Frame bekommt die URL ?crm_live=1. Bewährte Praxis (und genau das, was unser Referenz-Client macht): Läuft die Seite in einem iframe oder hat sie crm_live, holst du mit cache: 'no-store'. Bei uns ist ohnehin alles aktuell, mehr braucht es also nicht.
const isLive =
window.self !== window.top ||
new URLSearchParams(location.search).has('crm_live');
const res = await fetch('https://back.sitecog.com/content/v1/pages/home?lang=en', {
headers: { 'x-crm-key': 'pk_3f9c2a7e1b4d4c0e8a6f5b2d9c1e7a40' },
cache: isLive ? 'no-store' : 'default',
});type Props = { searchParams: Promise<{ crm_live?: string }> };
export default async function Home({ searchParams }: Props) {
const live = (await searchParams).crm_live === '1';
const res = await fetch('https://back.sitecog.com/content/v1/pages/home?lang=en', {
headers: { 'x-crm-key': process.env.CRM_KEY! },
// die Redaktion bekommt bei jedem Request frische Daten, Besucher die gecachte Kopie
...(live ? { cache: 'no-store' as const } : { next: { revalidate: 60 } }),
});
const page = await res.json();
// …
}Mehr zum Live-Modus und dem Markup dahinter findest du auf der Seite Widget & Markup.
„Warum sehe ich meine Änderung nicht?“
Die beliebteste Frage zu jedem Cache, ever. Geh diese Liste von oben nach unten durch — sie reicht von „in zehn Sekunden geprüft“ bis „mach dir erst mal einen Tee“.
- Frag die API direkt. Ein schlichtes
curlhat keinen Cache, und bei uns ist immer alles aktuell. Zeigt curl den neuen Text, ist die API in Ordnung und die alte Kopie steckt irgendwo unterwegs in einem Cache. Zeigt curl den alten Text, arbeite die Liste weiter ab. - Wurde wirklich gespeichert? Prüf es im CRM. Bei Blog-Posts gilt: Entwürfe werden nie ausgeliefert, und ein für die Zukunft geplanter Post bleibt bis zu seinem Datum unsichtbar (und kann ein paar Minuten später auftauchen).
- Richtige Website? Ein Key gehört zu genau einer Website. Staging und Produktion mit unterschiedlichen Keys lesen unterschiedlichen Content.
- Richtige Sprache? Einen Fallback auf dem Server gibt es nicht. Hat die Redaktion den deutschen Text geändert und du renderst Englisch, passiert sichtbar nichts. Eine leere Übersetzung kommt als
""zurück, und dein Fallback-Code zeigt dann womöglich still eine andere Sprache an. Siehe Sprachen & Fallbacks. - Richtiger Block? Marker unterscheiden Groß- und Kleinschreibung, und Block-Marker sind nur innerhalb einer Section eindeutig —
/v1/blocks/titleohne?section=liefert den ältesten Block mit diesem Marker, und das ist vielleicht nicht der, der geändert wurde. - Versteckte Section? Sections mit
show: falsewerden trotzdem ausgeliefert. Hat die Redaktion eine Section versteckt und sie ist immer noch auf der Website, prüft dein Templateshownicht. - Dein eigener Cache.
revalidate, ISR, eine In-Memory-Map, ein CDN vor deiner Website, eine Seite, die einmal zur Build-Zeit gebaut wurde. Das ist der übliche Verdächtige. - Der Browser. Bis zu 60 Sekunden plus ein Refresh im Hintergrund. Ein Hard Reload (Ctrl+Shift+R oder Cmd+Shift+R) klärt das.
- Bearbeitung im Live-Modus? Stell sicher, dass du im CRM-Frame mit
cache: 'no-store'holst (siehe oben).
curl -i "https://back.sitecog.com/content/v1/pages/home?lang=en" \
-H "x-crm-key: pk_3f9c2a7e1b4d4c0e8a6f5b2d9c1e7a40"