Die API bringt Inhalte auf deine Website. Diese Seite hier bringt dir die Redaktion vom Hals. Ein Script-Tag rein, ein paar data-crm-*-Attribute übers Markup streuen — und die Leute, denen die Texte gehören, klicken auf der echten Live-Seite auf eine Überschrift, korrigieren den Tippfehler und speichern. Kein Ticket, kein Deploy, kein „Kannst du mal eben ein Komma auf der Startseite ändern?“ am Freitag um 18 Uhr.
Die Einrichtung dauert vier Schritte, und nur bei einem musst du nachdenken:
Widget-Script einbinden
Ein<script>-Tag auf jeder Seite.Dem CRM das Einbetten erlauben
Ein Response-Header, damit das CRM deine Site in seinem Live-Modus öffnen kann.Editierbare Elemente markieren
Sag dem Editor perdata-crm-*-Attribut, welches Element welchen Block zeigt.Im Live-Modus frische Inhalte rendern
Umgeh deinen Cache, solange die Redaktion draufschaut, damit Änderungen sofort sichtbar sind.
Was die Redaktion davon hat
Aus Sicht der Redaktion sieht Live-Editing so aus:
- Sie öffnet deine Site im Live-Modus im CRM. Das ist deine echte Site, kein Mock-up.
- Jedes markierte Element bekommt beim Hovern einen Rahmen. Ein Klick aufs gewünschte — Überschrift, Absatz, Bild.
- Text ändern oder neues Bild hochladen, speichern.
- Die Seite lädt mit dem neuen Inhalt neu. Fertig. Niemand hat einen Code-Editor geöffnet.
Ist ein Element mit einem Block-Marker markiert, den es im CRM noch nicht gibt, kann die Redaktion den Block direkt von der Site aus anlegen. Du kannst also erst das Markup ausliefern und das Content-Team füllt es später.
Was unter der Haube passiert
Deine Seite rendert Inhalte aus der Content API genau wie vorher. Die data-crm-*-Attribute rendern nichts — sie verbinden nur ein DOM-Element mit einem Block im CRM, wie ein Etikett an einer Schublade.
- Der Live-Modus ist ein iframe. Das CRM lädt deine Site in einem Frame und hängt
?crm_live=1an die URL. Den Editor lädt das Widget nur in genau diesem Frame — bettet jemand anderes deine Site in sein eigenes iframe ein, gibt es dort keinen Editor. - Ein Script lädt, was nötig ist — und nur das.
widget.jsist der einzige Tag, den du einbaust. Der Editor selbst (widget.editor.js) wird nur geladen, wenn die Site im Live-Modus des CRM geöffnet ist. Der Support-Chat (widget.support.js) kommt nur mit, wenn der Chat im CRM aktiviert ist. Der Login für Besucher (widget.auth.js) nur, wenn die Seitedata-crm-login- oderdata-crm-auth-Elemente oder eindata-crm-key-Attribut hat. - Besucher zahlen nicht dafür. Außerhalb des CRM wird nichts Editor-Bezogenes geladen — deine Besucher laden den Editor nie herunter.
- Speichern ist eine ganz normale CRM-Änderung. Das CRM schreibt den Block, die Content-Version der Site steigt, und der nächste API-Request liefert frische Daten.
- Den Tag aus Versehen zweimal eingebunden? Kein Problem: Die zweite Kopie wird ignoriert.
Schritt 1. Widget-Script einbinden
Setz den Tag auf jede Seite, direkt vor </body>. Hat deine Site ein gemeinsames Layout, ist das der eine Ort dafür.
<!doctype html>
<html lang="en">
<head>…</head>
<body>
…deine Seite…
<script src="https://widget.sitecog.com/widget.js" defer></script>
</body>
</html>// app/layout.tsx
import Script from 'next/script';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
{children}
<Script src="https://widget.sitecog.com/widget.js" strategy="afterInteractive" />
</body>
</html>
);
}<!-- index.html im Projekt-Root -->
<!doctype html>
<html lang="en">
<head>…</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
<script src="https://widget.sitecog.com/widget.js" defer></script>
</body>
</html>Das war's fürs Script. Kein Key, kein Init-Aufruf, kein Config-Objekt — das Widget merkt selbst, ob es im CRM läuft.
Schritt 2. Dem CRM das Einbetten erlauben
Der Live-Modus zeigt deine Site in einem iframe auf https://sitecog.com. Browser erlauben das nur, wenn deine Site es ausdrücklich sagt. Deine Responses brauchen zwei Dinge:
- einen
Content-Security-Policy-Header mitframe-ancestors 'self' https://sitecog.com; - keinen
X-Frame-Options-Header mitDENYoderSAMEORIGIN— der sticht alle guten Absichten aus und blockiert den Frame.
Wähl deinen Server:
server {
# …
# Dem Diil CRM erlauben, die Site im Live-Modus zu öffnen
add_header Content-Security-Policy "frame-ancestors 'self' https://sitecog.com" always;
# Lösche alle Zeilen "add_header X-Frame-Options …" in diesem server-Block.
# Setzt die App hinter proxy_pass X-Frame-Options selbst, entferne ihn hier:
proxy_hide_header X-Frame-Options;
}# .htaccess oder die VirtualHost-Config (braucht mod_headers)
<IfModule mod_headers.c>
Header always set Content-Security-Policy "frame-ancestors 'self' https://sitecog.com"
Header always unset X-Frame-Options
</IfModule>// next.config.ts
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
async headers() {
return [
{
source: '/:path*',
headers: [
{
key: 'Content-Security-Policy',
value: "frame-ancestors 'self' https://sitecog.com",
},
],
},
];
},
};
export default nextConfig;// Vor deinen Routen
app.use((req, res, next) => {
res.removeHeader('X-Frame-Options');
res.setHeader('Content-Security-Policy', "frame-ancestors 'self' https://sitecog.com");
next();
});
// Du nutzt helmet? Das schickt standardmäßig X-Frame-Options: SAMEORIGIN. Konfigurier es stattdessen so:
// app.use(helmet({
// xFrameOptions: false,
// contentSecurityPolicy: {
// directives: { frameAncestors: ["'self'", 'https://sitecog.com'] },
// },
// }));Schritt 3. Editierbare Elemente markieren
Jetzt sagst du dem Editor, was was ist. Jedes Attribut bedeutet „dieses Element zeigt jenen Block“. Der Wert ist ein Pfad, der mit dem Block-Marker beginnt, den du im CRM vergeben hast.
Attribut-Referenz
| Attribut | Gehört an | Was die Redaktion damit kann |
|---|---|---|
data-crm-text | Jedes Element, das Text zeigt: h1, p, span, eine Button-Beschriftung | Den Text eines Textblocks oder Textfelds bearbeiten |
data-crm-image | Das <img>, das einen Bildblock oder ein Bildfeld zeigt | Das Bild hochladen oder ersetzen |
data-crm-video | Das <video>, das einen Videoblock oder ein Videofeld zeigt | Das Video hochladen oder ersetzen |
data-crm-object | Den Container, der einen object-Block rendert (eine Sektion, eine Karte) | Die Feldgruppe als einen Block sehen |
data-crm-array | Den Container, der eine Liste rendert — einen array-Block oder ein Array-Feld | Die Liste als Ganzes sehen |
Einfache Blöcke brauchen nur ihren Marker:
<section>
<h1 data-crm-text="hero_title">{hero.hero_title.content.en}</h1>
<p data-crm-text="hero_subtitle">{hero.hero_subtitle.content.en}</p>
<img data-crm-image="hero_image" src={hero.hero_image.content.file} alt="" />
<video data-crm-video="hero_video" src={hero.hero_video.content.file} autoPlay muted loop />
</section>Pfadsyntax: in Objekte und Arrays hinein
Object- und Array-Blöcke haben Felder im Inneren, also geht der Pfad mit Punkten weiter. Das erste Segment ist immer der Block-Marker. Danach kommen Feld-Marker von Objekten und numerische Array-Indizes (ab 0).
| Pfad | Zeigt auf |
|---|---|
hero_title | Den ganzen Block hero_title |
faq_section.title | Feld title des Object-Blocks faq_section |
faq_section.items | Das Array-Feld items |
faq_section.items.0.question | Feld question des ersten Eintrags |
faq_section.items.2.answer | Feld answer des dritten Eintrags |
Die Regeln passen in vier Zeilen:
- Segmente werden durch Punkte getrennt; jedes besteht aus lateinischen Buchstaben, Ziffern und Unterstrichen, 1–40 Zeichen;
- das erste Segment, der Block-Marker, ist mindestens 2 Zeichen lang (die üblichen Marker-Regeln);
- ein Schritt in ein Objekt ist ein Feld-Marker, ein Schritt in ein Array ist eine Zahl;
- Marker unterscheiden Groß- und Kleinschreibung:
Hero_titleundhero_titlesind zwei verschiedene Blöcke.
Komplettes Beispiel: eine FAQ-Sektion
Hier die Daten: ein object-Block mit einem Titel und einem Array von Fragen.
"faq_section": {
"type": "object",
"content": {
"title": { "en": "FAQ" },
"items": [
{
"question": { "en": "How long is delivery?" },
"answer": { "en": "1–3 days." }
},
{
"question": { "en": "Can I return the earbuds?" },
"answer": { "en": "Yes, within 14 days." }
}
]
}
}Und hier das Markup. Das Objekt bekommt data-crm-object, die Liste bekommt data-crm-array, und jeder Text darin bekommt einen vollen Pfad mit dem Index des Eintrags:
type LangMap = Record<string, string>;
type FaqItem = { question: LangMap; answer: LangMap };
type FaqBlock = { content: { title: LangMap; items: FaqItem[] } };
export function Faq({ block, lang }: { block: FaqBlock; lang: string }) {
const { title, items } = block.content;
return (
<section data-crm-object="faq_section">
<h2 data-crm-text="faq_section.title">{title[lang]}</h2>
<div data-crm-array="faq_section.items">
{items.map((item, i) => (
<details key={i}>
<summary data-crm-text={`faq_section.items.${i}.question`}>
{item.question[lang]}
</summary>
<p data-crm-text={`faq_section.items.${i}.answer`}>
{item.answer[lang]}
</p>
</details>
))}
</div>
</section>
);
}
// Verwendung: <Faq block={page.content.faq.content.faq_section} lang="en" /><section data-crm-object="faq_section">
<h2 data-crm-text="faq_section.title">FAQ</h2>
<div data-crm-array="faq_section.items">
<details>
<summary data-crm-text="faq_section.items.0.question">How long is delivery?</summary>
<p data-crm-text="faq_section.items.0.answer">1–3 days.</p>
</details>
<details>
<summary data-crm-text="faq_section.items.1.question">Can I return the earbuds?</summary>
<p data-crm-text="faq_section.items.1.answer">Yes, within 14 days.</p>
</details>
</div>
</section>Arrays in Arrays funktionieren genauso — Feld-Marker und Indizes einfach weiter abwechseln, z. B. pricing.plans.1.features.0.text.
Schritt 4. Im Live-Modus frische Inhalte rendern
Bei uns landet jede CRM-Änderung sofort in der API. Deine Site hat aber vielleicht einen eigenen Cache: Der Browser hält API-Responses bis zu 60 Sekunden, ein Next.js-revalidate für sein eigenes Zeitfenster. Besucher merken davon nichts. Eine Redakteurin, die gerade auf Speichern gedrückt hat und immer noch den alten Text sieht, schon.
Die Lösung: Ist die Seite im Live-Modus geöffnet, fetch mit cache: 'no-store'. Den Live-Modus erkennst du am URL-Parameter crm_live oder daran, dass die Seite in einem iframe läuft. Unsere eigene Referenz-Site macht genau das:
// crm.ts — ist die Seite im Live-Modus des CRM geöffnet?
export function isCrmLive(): boolean {
if (typeof window === 'undefined') return false;
try {
if (new URLSearchParams(window.location.search).has('crm_live')) return true;
// Nach einem internen Link kann der Parameter verloren gehen — der iframe-Check fängt das ab
return window.parent !== window;
} catch {
return false;
}
}
export async function getPage(marker: string) {
const res = await fetch(`https://back.sitecog.com/content/v1/pages/${marker}`, {
headers: { 'x-crm-key': 'pk_3f9c2a7e1b4d4c0e8a6f5b2d9c1e7a40' },
// Die Redaktion bekommt immer frische Inhalte, Besucher die schnelle gecachte Version
cache: isCrmLive() ? 'no-store' : 'default',
});
if (!res.ok) throw new Error(`Content API: ${res.status}`);
return res.json();
}// app/page.tsx — eine Server Component sieht nur die URL, also prüft sie crm_live
type Props = { searchParams: Promise<Record<string, string | string[] | undefined>> };
export default async function Home({ searchParams }: Props) {
const live = 'crm_live' in (await searchParams);
const res = await fetch('https://back.sitecog.com/content/v1/pages/home?lang=en', {
headers: { 'x-crm-key': process.env.CRM_KEY! },
// Live-Modus: direkt aus der API. Alle anderen: 60 Sekunden gecacht
...(live ? { cache: 'no-store' as const } : { next: { revalidate: 60 } }),
});
const page = await res.json();
const hero = page.content.hero.content;
return <h1 data-crm-text="hero_title">{hero.hero_title.content.en}</h1>;
}Was wo und wie lange gecacht wird, steht auf der Seite Caching & ETag.
Checkliste
- Der
widget.js-Tag steht auf jeder Seite, vor</body>. - Responses enthalten
frame-ancestors 'self' https://sitecog.com. - Nirgends ein
X-Frame-Options-Header — prüf auch Hosting-Panel, CDN und Framework-Defaults. - Editierbare Elemente haben
data-crm-*-Attribute, und die Marker stimmen Buchstabe für Buchstabe mit dem CRM überein. - Objekte und Listen sind in
data-crm-object/data-crm-arrayverpackt, und die inneren Pfade nutzen die richtigen Indizes. - Im Live-Modus holt die Site Inhalte mit
cache: 'no-store'. - Du hast die Site im Live-Modus geöffnet, eine Überschrift angeklickt, geändert und die Änderung gesehen. 🎉
Fehlersuche
„Die Website verbietet das Einbetten“
Das CRM wollte deine Site in einem Frame öffnen, und der Browser hat Nein gesagt. Die üblichen Verdächtigen:
- die
frame-ancestors-Direktive fehlt, oderhttps://sitecog.comsteht nicht drin; - irgendwas schickt immer noch
X-Frame-Options: ein Hosting-Panel, ein CDN, ein Security-Plugin, helmet in Express; - die CSP ist per
<meta>statt per Header gesetzt, also wirdframe-ancestorsignoriert; - der Header ist für einen Host konfiguriert, die Site öffnet aber auf einem anderen (mit oder ohne
www).
Prüf, was dein Server tatsächlich schickt:
curl -sI https://your-site.com | grep -iE "content-security-policy|x-frame-options"Ein Element ist im Live-Modus nicht klickbar
- Das Attribut fehlt im gerenderten HTML. Schau dir die Seite in den DevTools an, nicht den Quelltext — manche Komponenten reichen unbekannte Props nicht ans DOM durch.
- Der Pfad ist kaputt: ein Leerzeichen, ein Bindestrich, ein nicht-lateinischer Buchstabe, ein Punkt am Ende. Das Widget schreibt eine Warnung zum Marker-Format in die Browser-Konsole.
- Nur der Container ist markiert.
data-crm-objectunddata-crm-arraygruppieren; klickbar sind die Texte und Bilder darin, und die brauchen ihr eigenesdata-crm-text/data-crm-image. - Das Widget-Script fehlt auf genau dieser Seite — leicht zu übersehen, wenn eine Site mehrere Layouts hat.
Gespeichert, aber die Änderung ist nicht zu sehen
- Dein Fetch ist gecacht. Nutz im Live-Modus
cache: 'no-store'(Schritt 4). - Die Seite ist komplett statisch — einmal beim Deploy gebaut —, sie kann von neuen Inhalten also erst beim nächsten Build erfahren. Lass sie zur Request-Zeit fetchen, zumindest im Live-Modus.
- Das Element zeigt hart codierten Text oder einen Fallback statt des Werts aus der API: Das Attribut ist da, die Daten nicht.
- Der Pfad zeigt woandershin als das, was gerendert wird — z. B. zeigt das Element Eintrag
1, ist aber alsitems.0markiert. - Der Site-Key gehört zu einer anderen Site, oder die Seite rendert eine andere Sprache als die, die gerade bearbeitet wird. Siehe Sprachen & Fallbacks.
Das Widget kann noch mehr
Derselbe widget.js-Tag zählt Seitenaufrufe, sendet eigene Events mit window.crmTrack(name, params), macht form[data-crm-lead]-Formulare zu CRM-Leads und zeigt einen Live-Support-Chat. Keine zusätzlichen Skripte — nimm einfach, was du brauchst: