Fünf Minuten, eine Seite, null Admin-Panels. Am Ende dieser Anleitung kommt eine Überschrift auf deiner Website aus dem Diil CRM — und deine Redaktion kann sie ändern, indem sie einfach draufklickt. Hol dir einen Kaffee; vielleicht wirst du ihn nicht mal austrinken.
Du brauchst:
- Zugriff auf eine Website im Diil CRM (für Experimente kannst du einfach eine neue anlegen);
- ein Terminal mit
curloder irgendein Tool, das HTTP-Requests verschicken kann; - ein beliebiges Frontend: eine einfache HTML-Datei, React, Next.js, Nuxt — du hast die Wahl.
Inhalte und Key einrichten
Leg Inhalte im CRM an
Öffne deine Website im CRM und prüf, ob die Sprachen, die du brauchst, angelegt sind (sagen wir, Englisch). Dann erstelle:
- eine Seite mit dem Marker
home; - darin eine Section mit dem Marker
hero; - darin einen Text-Block mit dem Marker
hero_title— tipp eine Überschrift ein.
Marker sind die Namen, über die dein Code Inhalte findet: lateinische Buchstaben, Ziffern und Unterstriche, 2–40 Zeichen, Groß- und Kleinschreibung zählt. Wähl sie wie Variablennamen — sie werden lange in deinem Code wohnen. Hier gibt's das Gesamtbild von Seiten, Sections und Blöcken.
- eine Seite mit dem Marker
Hol dir einen Site-Key
Geh zu Einstellungen → Content-API-Keys und erstelle einen Key. Er sieht aus wie
pk_plus 32 Hex-Zeichen und ist an genau diese eine Website gebunden.Ein neuer Key funktioniert meistens sofort; im schlimmsten Fall gib ihm ein paar Minuten. Beim Widerrufen ist es genauso — widerrufene Keys bekommen
401 invalid_key.Schick deinen ersten Request
Frag die Seite
homeauf Englisch an:curl "https://back.sitecog.com/content/v1/pages/home?lang=en" \ -H "x-crm-key: pk_3f9c2a7e1b4d4c0e8a6f5b2d9c1e7a40"const res = await fetch('https://back.sitecog.com/content/v1/pages/home?lang=en', { headers: { 'x-crm-key': 'pk_3f9c2a7e1b4d4c0e8a6f5b2d9c1e7a40' }, }); console.log(await res.json());200 OKjson { "id": 26, "marker": "home", "name": "Home", "href": "/", "index": 0, "params": {}, "content": { "hero": { "id": 41, "marker": "hero", "name": "Hero", "index": 0, "show": true, "content": { "hero_title": { "id": 95, "marker": "hero_title", "name": "Hero title", "type": "text", "multilang": true, "updatedAt": "2026-09-20T16:33:23.000Z", "content": { "en": "Earbuds that mute the city" } } } } } }Siehst du den Pfad zu deiner Überschrift?
content.hero.content.hero_title.content.en— Seite → Section → Block → Sprache. Jede Seite hat genau diese Form; die Details findest du unter Seiten.
Auf deiner Seite rendern
Derselbe Request in vier Geschmacksrichtungen. Such dir deine aus — alle machen dasselbe: Seite laden und die Überschrift in ein <h1> packen.
<h1 id="hero-title"></h1>
<script type="module">
const res = await fetch('https://back.sitecog.com/content/v1/pages/home?lang=en', {
headers: { 'x-crm-key': 'pk_3f9c2a7e1b4d4c0e8a6f5b2d9c1e7a40' },
});
const page = await res.json();
const hero = page.content.hero.content;
document.getElementById('hero-title').textContent = hero.hero_title.content.en;
</script>// Hero.jsx — eine Client-Komponente (Vite, CRA, egal was)
import { useEffect, useState } from 'react';
const API = 'https://back.sitecog.com/content';
const KEY = import.meta.env.VITE_CRM_KEY; // öffentlicher Key, im Browser völlig okay
export function Hero() {
const [hero, setHero] = useState(null);
useEffect(() => {
fetch(API + '/v1/pages/home?lang=en', { headers: { 'x-crm-key': KEY } })
.then((res) => res.json())
.then((page) => setHero(page.content.hero.content));
}, []);
if (!hero) return null;
return <h1>{hero.hero_title.content.en}</h1>;
}// app/page.tsx — eine Server Component: läuft auf dem Server, der Key bleibt dort
const API = 'https://back.sitecog.com/content';
export default async function Home() {
const res = await fetch(API + '/v1/pages/home?lang=en', {
headers: { 'x-crm-key': process.env.CRM_KEY! },
next: { revalidate: 60 }, // die API höchstens einmal pro Minute fragen
});
if (!res.ok) throw new Error('Content API: ' + res.status);
const page = await res.json();
const hero = page.content.hero.content;
return <h1>{hero.hero_title.content.en}</h1>;
}<!-- pages/index.vue -->
<script setup lang="ts">
const config = useRuntimeConfig(); // NUXT_PUBLIC_CRM_KEY → config.public.crmKey
const { data: page } = await useFetch('https://back.sitecog.com/content/v1/pages/home', {
query: { lang: 'en' },
headers: { 'x-crm-key': config.public.crmKey },
});
const hero = computed(() => page.value?.content.hero.content);
</script>
<template>
<h1>{{ hero?.hero_title.content.en }}</h1>
</template>Wohin mit dem Key
Leg den Key in eine Umgebungsvariable statt in den Code — nicht weil er geheim wäre, sondern weil dann der Wechsel der Website oder das Rotieren des Keys eine Änderung in einer einzigen Zeile ist.
# .env.local (Next.js) — nur auf dem Server, landet nie im Browser
CRM_KEY=pk_3f9c2a7e1b4d4c0e8a6f5b2d9c1e7a40
# Browser-Code braucht eine öffentliche Variable. Kein Problem: Der Key ist read-only.
NEXT_PUBLIC_CRM_KEY=pk_3f9c2a7e1b4d4c0e8a6f5b2d9c1e7a40
# Vite: VITE_CRM_KEY=… Nuxt: NUXT_PUBLIC_CRM_KEY=…In einer Server Component (wie im Next.js-Tab oben) nimm die reine Server-Variable: Dann verlässt der Key nie deinen Server. Für Browser-Code ist eine öffentliche Variable völlig in Ordnung.
Einen Sprach-Helper einbauen
Text-Blöcke kommen als Sprach-Maps: { "en": "…", "de": "…" }. Die API tauscht nie eine Sprache gegen eine andere aus: Fehlt eine Übersetzung, ist sie einfach nicht da (oder ein leerer String, wenn das Feld leer gelassen wurde). Welcher Fallback greift, entscheidest du — und mit diesem winzigen Helper ist das ein Einzeiler:
// lib/t.js
// Übersetzung wählen: gewünschte Sprache → Fallback-Sprache → erste nicht leere → ''
export function t(map, lang, fallback = 'en') {
if (!map) return '';
return map[lang] || map[fallback] || Object.values(map).find(Boolean) || '';
}// lib/t.ts
export type LangMap = Record<string, string>;
// Übersetzung wählen: gewünschte Sprache → Fallback-Sprache → erste nicht leere → ''
export function t(map: LangMap | null | undefined, lang: string, fallback = 'en'): string {
if (!map) return '';
return map[lang] || map[fallback] || Object.values(map).find(Boolean) || '';
}// Sprache des Besuchers und Fallback in einem Request anfragen
const res = await fetch('https://back.sitecog.com/content/v1/pages/home?lang=de,en', {
headers: { 'x-crm-key': 'pk_3f9c2a7e1b4d4c0e8a6f5b2d9c1e7a40' },
});
const page = await res.json();
const hero = page.content.hero.content;
t(hero.hero_title.content, 'de'); // "Kopfhörer, die die Stadt stummschalten"
t(hero.hero_title.content, 'de', 'en'); // Deutsch noch nicht ausgefüllt? → der englische TextJeder Code in ?lang= muss eine aktive Sprache der Website sein, sonst bekommst du 400 unknown_lang samt Liste der verfügbaren. Lässt du lang weg, bekommst du alle aktiven Sprachen auf einmal. Die ganze Geschichte steht unter Sprachen & Fallbacks.
Live-Bearbeitung einschalten
Jetzt kommt der spaßige Teil. Deine Seite zeigt schon Inhalte aus dem CRM; jetzt lassen wir die Redaktion sie direkt auf der Seite ändern — ohne in einem Formular nach dem richtigen Feld zu suchen.
Das Widget einbinden
Ein Script, einmal pro Seite, direkt vor
</body>. Es lädt den Editor nur, wenn deine Website im CRM geöffnet ist — normale Besucher laden davon kein einziges Byte.<script src="https://widget.sitecog.com/widget.js" defer></script>Sag dem Editor, welches Element welchen Block zeigt
Häng
data-crm-textmit dem Block-Marker an das Element, das ihn rendert. Den Text renderst du weiterhin wie bisher aus der API — das Attribut verbindet nur das Element mit dem Block.<body> <h1 data-crm-text="hero_title">Earbuds that mute the city</h1> <!-- einmal pro Seite, direkt vor </body> --> <script src="https://widget.sitecog.com/widget.js" defer></script> </body>// app/layout.tsx export default function RootLayout({ children }: { children: React.ReactNode }) { return ( <html lang="en"> <body> {children} <script src="https://widget.sitecog.com/widget.js" defer /> </body> </html> ); } // app/page.tsx — gleiches Rendering wie vorher, plus ein Attribut return <h1 data-crm-text="hero_title">{hero.hero_title.content.en}</h1>;Bilder, Videos, Objekte und Arrays haben eigene Attribute (
data-crm-image,data-crm-video,data-crm-object,data-crm-array) — siehe Widget & Markup. Für Besucher sind die Attribute harmlos, lass sie also ruhig in Production drin.Erlaub dem CRM, deine Website einzubetten
Der Live-Modus öffnet deine Website im CRM, in einem iframe. Dein Server muss das per
frame-ancestorserlauben und darf keinX-Frame-Options: DENYoderSAMEORIGINschicken. Sonst sagt dir das CRM, dass die Website das Einbetten verbietet.// next.config.js module.exports = { async headers() { return [{ source: '/:path*', headers: [ { key: 'Content-Security-Policy', value: "frame-ancestors 'self' https://sitecog.com" }, ], }]; }, };# nginx — und achte darauf, dass nichts X-Frame-Options: DENY oder SAMEORIGIN schickt add_header Content-Security-Policy "frame-ancestors 'self' https://sitecog.com";Klicken, tippen, speichern
Öffne deine Website im CRM im Live-Modus, klick auf die Überschrift, änder sie und speichere. Das CRM schreibt den Block, die Content-Version zählt hoch, deine Seite holt frische Inhalte — und die neue Überschrift ist da. 🎉
Bonus: Setzt du
data-crm-text="promo_note"auf ein Element, bevor es so einen Block gibt, kann die Redaktion den Block direkt von der Website aus anlegen.
Den Cache für die Redaktion überspringen
Wir liefern im Live-Modus nie veraltete Inhalte aus. Aber dein Cache kann das: Mit revalidate: 60 speichert jemand und sieht bis zu einer Minute lang noch den alten Text. Im CRM-Frame bekommt die URL ?crm_live=1 — nutz das, um mit cache: 'no-store' zu laden:
// app/page.tsx — jeden Cache überspringen, solange jemand aus der Redaktion zuschaut
const API = 'https://back.sitecog.com/content';
export default async function Home({ searchParams }: { searchParams: Promise<{ crm_live?: string }> }) {
const live = (await searchParams).crm_live === '1';
const res = await fetch(API + '/v1/pages/home?lang=en', {
headers: { 'x-crm-key': process.env.CRM_KEY! },
...(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>;
}// Im CRM-Frame (oder mit ?crm_live=1) immer frische Inhalte anfragen
const live = 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: live ? 'no-store' : 'default',
});Wie geht's weiter
Die Inhalte fließen, die Redaktion klickt. Hier geht's in die Tiefe: