Ein Block ist das kleinste Stück Inhalt, das die Redaktion ändern kann: eine Headline, ein Preis, ein Hero-Foto, eine komplette FAQ. Es gibt zwölf Blocktypen, und das Feld type sagt dir genau, welche Form content hat. Lern die zwölf Formen einmal, und du renderst jede Diil-Site, ohne zu raten.
Jeder Block hat dieselbe Hülle
Egal, welcher Endpoint dir einen Block liefert — eine Seite, eine Sektion oder ein einzelner Block —, er steckt immer in derselben Hülle. Nur content ändert sich von Typ zu Typ.
{
"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", "de": "Kopfhörer, die die Stadt leise machen" }
}idnumbermarkerstringhero_title. Eindeutig innerhalb seiner Sektion.namestringtypestringcontent.multilangbooleantrue, wenn jede Sprache ihre eigene Datei hat.updatedAtstring (ISO 8601)contentdepends on typeAlle Blocktypen auf einen Blick
Setz dir ein Lesezeichen auf diese Tabelle. Sie beantwortet für jeden Typ die Frage „wie sieht content aus?“.
| Typ | content | Typischer Einsatz |
|---|---|---|
text | { "en": "…", "de": "…" } | Headlines, Buttons, kurze Texte |
html | { "en": "<p>…</p>" } | Formatierter Text: Absätze, Listen, Links |
image | { "file": "https://…" } oder { "multilang": true, "en": "…" } | Fotos, Banner, Logos |
video | wie bei image | Hintergrund- und Produktvideos |
link | { "url", "target", "title": { "en": "…" } } | Buttons, Menüpunkte, Calls to Action |
number | 149 oder null | Preise, Zähler, Schwellenwerte |
color | "#3D3D5C" oder null | Akzentfarben, Themes |
date | "2026-10-01" oder null | Event-Termine, Deadlines |
date_range | { "from": "…", "to": "…" } oder null | Aktionen, Saisons |
boolean | true, false oder null | Schalter zum Ein- und Ausblenden |
object | { "field": value, … } | Eine Gruppe von Feldern: eine Karte, eine FAQ, ein Kontaktkasten |
array | [ { "field": value, … }, … ] | Wiederkehrende Einträge: Team, Reviews, Features |
Die Beispiele unten nutzen zwei winzige Helper: t() für Sprach-Maps und pickMedia() für Dateien. Beide stecken im universellen Renderer am Ende der Seite, fertig zum Kopieren.
text — einfacher Text
Im CRM tippt die Redaktion einfachen Text, ein Wert pro Sprache. Keine Formatierung, keine Überraschungen.
{ "en": "Earbuds that mute the city", "de": "Kopfhörer, die die Stadt leise machen" }<h1>{t(block.content, lang, defaultLang)}</h1>- Die Keys sind Sprachcodes in der Reihenfolge aus dem CRM, nicht in der Reihenfolge, in der du sie angefragt hast.
- Eine Übersetzung, die die Redaktion geleert hat, kommt als
"". Eine Sprache, die nie ausgefüllt wurde, fehlt einfach. Dein Code muss beides überleben —t()tut es. - React escaped Text für dich, ein verirrtes
<in einer Headline ist also harmlos.
html — formatierter Text
Im CRM bekommt die Redaktion formatierten Text — fett, Listen, Links und so weiter. Du bekommst das resultierende HTML, ein String pro Sprache.
{
"en": "<p>Free delivery on orders over <strong>$50</strong>.</p><ul><li>1–3 days</li><li>Tracking included</li></ul>",
"de": "<p>Kostenloser Versand ab <strong>50 $</strong>.</p>"
}<div
className="prose"
dangerouslySetInnerHTML={{ __html: t(block.content, lang, defaultLang) }}
/>Die inneren Tags stylst du per CSS (eine .prose-Klasse oder Ähnliches). Die Redaktion entscheidet, was fett ist, du entscheidest, wie fett aussieht.
image — Bilder
Im CRM lädt die Redaktion ein Bild hoch: entweder eine Datei für alle Sprachen oder, wenn der Block mehrsprachig ist, eine eigene Datei pro Sprache (praktisch für Banner mit eingebranntem Text im Bild). Diese Wahl ändert die Form von content:
{ "file": "https://cdn.example.com/storage/your-site/hero.jpg" }{
"multilang": true,
"en": "https://cdn.example.com/storage/your-site/banner-en.jpg",
"de": ""
}const src = pickMedia(block.content, lang, defaultLang);
{src && <img src={src} alt={t(altBlock.content, lang, defaultLang)} />}- Prüf
content.multilang, um die beiden Formen auseinanderzuhalten. Das Flag reist mit dem Wert mit, funktioniert also auch in Objekten und Arrays, wo es keine Block-Hülle gibt. - Eine Sprache, deren Datei noch nicht hochgeladen ist, ist
""— wiedeoben. Fall auf eine andere Sprache zurück, statt<img src="">zu rendern. - URLs kommen schon escaped an (Leerzeichen und Konsorten). Nutz sie, wie sie sind: Nochmal encodiert wird aus
%20ein%2520und aus dem Bild ein kaputtes Icon. - Ein Bildblock hat keinen Alt-Text. Pack ihn in einen Textblock neben dem Bild, damit die Redaktion ihn übersetzen kann.
video — Videodateien
Im CRM lädt die Redaktion eine Videodatei hoch. Alles, was für Bilder gilt, gilt hier auch: dieselben zwei Formen, dieselben leeren Strings, dieselben direkt nutzbaren URLs.
{ "file": "https://cdn.example.com/storage/your-site/promo.mp4" }const src = pickMedia(block.content, lang, defaultLang);
{src && <video src={src} autoPlay muted loop playsInline />}Es ist eine Datei, kein Embed-Code, ein einfacher <video>-Tag reicht also. Hintergrundvideos brauchen muted und playsInline, sonst verweigern mobile Browser das Autoplay.
link — Links und Buttons
Im CRM legt die Redaktion die Adresse fest, ob sie im selben oder in einem neuen Tab öffnet, und den Linktext für jede Sprache.
{
"url": "https://example.com/pricing",
"target": "_blank",
"title": { "en": "See pricing", "de": "Preise ansehen" }
}const { url, target, title } = block.content;
<a
href={url}
target={target}
rel={target === '_blank' ? 'noopener noreferrer' : undefined}
>
{t(title, lang, defaultLang)}
</a>targetist entweder_selfoder_blankund geht damit direkt in den Tag.- Gib
_blank-Linksrel="noopener noreferrer"mit — gutes Benehmen und gute Security. titleist eine Sprach-Map mit denselben Regeln wie ein Textblock: Leerer String oder fehlende Sprache sind möglich.
Rohwerte: number, color, date, date_range, boolean
Diese fünf werden nicht übersetzt, es gibt also keine Sprach-Map: content ist der Wert selbst, genau so, wie er gespeichert ist. Hat die Redaktion noch nichts gesetzt, bekommst du null.
number
Im CRM: ein Zahlenfeld. Preise, „Jahre am Markt“, die Schwelle für kostenlosen Versand.
149{block.content !== null && (
<span className="price">{new Intl.NumberFormat(lang).format(block.content)}</span>
)}color
Im CRM: eine Farbe. Eine Akzentfarbe, der Hintergrund eines Promo-Streifens.
"#3D3D5C"<section style={{ background: block.content ?? '#ffffff' }}>…</section>Eine Farbe ist eine Einstellung, nichts zum Ausgeben. Gib sie an style oder eine CSS-Variable weiter und halte immer einen Default für null bereit.
date
Im CRM: ein Datum. Wann das Event startet, wann das Angebot endet.
"2026-10-01"{block.content && (
<time dateTime={block.content}>
{new Date(block.content).toLocaleDateString(lang, { timeZone: 'UTC' })}
</time>
)}Der Wert kommt so zurück, wie er im CRM eingegeben wurde, also logg eine echte Response, bevor du ein Format hart codierst. Und eine klassische Falle: new Date('2026-10-01') ist Mitternacht UTC, formatier es also mit timeZone: 'UTC', sonst sehen Besucher westlich von Greenwich den 30. September.
date_range
Im CRM: ein Start- und ein Enddatum. Eine Sale-Woche, eine Sommersaison.
{ "from": "2026-10-01", "to": "2026-10-07" }const range = block.content;
const fmt = (d: string) => new Date(d).toLocaleDateString(lang, { timeZone: 'UTC' });
{range?.from && <p>{fmt(range.from)} – {range.to ? fmt(range.to) : '…'}</p>}Gleiche Regel wie bei date: Der Wert wird so gespeichert, wie er im CRM eingegeben wurde, also prüf eine echte Response und programmier defensiv.
boolean
Im CRM: ein An/Aus-Schalter. „Sale-Banner anzeigen“, „Wir nehmen Bestellungen an“.
true{(block.content ?? false) && <SaleBanner />}Drei Zustände, nicht zwei: true, false und null (nie gesetzt). Leg fest, was null auf deiner Site bedeutet, und schreib es mit ?? explizit hin.
object — eine Gruppe von Feldern
Im CRM sieht ein Object-Block aus wie ein kleines Formular: mehrere benannte Felder, die zusammengehören — eine Kontaktkarte, ein Preisplan, eine FAQ mit Titel und Liste von Fragen.
content ist ein Objekt mit Feld-Markern als Keys. Jedes Feld folgt den Regeln seines eigenen Typs: Textfelder sind Sprach-Maps, Linkfelder sind { url, target, title }, Bildfelder sind { file } oder { multilang, … }, alles andere ist ein Rohwert. Ein Feld kann sogar eine Liste von Einträgen sein.
"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 it?" }, "answer": { "en": "Within 30 days." } }
]
}
}import { t, type Block, type LangMap, type ObjectValue, type RenderContext } from '@/lib/diil';
export function Faq(block: Block, { lang, fallback }: RenderContext) {
if (block.type !== 'object') return null;
const items = (block.content.items ?? []) as ObjectValue[];
return (
<section>
<h2>{t(block.content.title as LangMap, lang, fallback)}</h2>
{items.map((item, i) => (
<details key={i}>
<summary>{t(item.question as LangMap, lang, fallback)}</summary>
<p>{t(item.answer as LangMap, lang, fallback)}</p>
</details>
))}
</section>
);
}array — eine Liste von Einträgen
Im CRM ist ein Array eine Liste wiederkehrender Einträge mit denselben Feldern: Teammitglieder, Reviews, Feature-Karten. Die Redaktion fügt Einträge hinzu und entfernt sie, und du bekommst sie in der Reihenfolge, die das CRM zeigt.
content ist ein schlichtes JSON-Array. Jeder Eintrag ist genau wie der Inhalt eines Object-Blocks aufgebaut: Felder nach Marker.
[
{
"title": { "en": "Noise cancelling" },
"text": { "en": "Up to 40 dB quieter." },
"icon": { "file": "https://cdn.example.com/storage/your-site/anc.svg" }
},
{
"title": { "en": "42 hours" },
"text": { "en": "With the charging case." },
"icon": { "file": "https://cdn.example.com/storage/your-site/battery.svg" }
}
]<ul className="features">
{block.content.map((item, i) => (
<li key={i}>
<img src={pickMedia(item.icon as Media, lang, defaultLang)} alt="" />
<h3>{t(item.title as LangMap, lang, defaultLang)}</h3>
<p>{t(item.text as LangMap, lang, defaultLang)}</p>
</li>
))}
</ul>- Ein Eintrag besteht nur aus seinen Feldern, nimm also den Index als React-Key (oder ein Feld, von dem du weißt, dass es eindeutig ist).
- Eine leere Liste ist
[]. Entscheide, ob dann die ganze Sektion verschwinden soll, wenn es nichts zu zeigen gibt. - Die Redaktion soll Listeneinträge direkt auf der Site ändern können? Markier die Liste mit
data-crm-array— siehe Live-Editing.
Spickzettel: leere Werte
Grund Nummer eins für „warum ist da eine leere Stelle auf der Seite?“. So sieht „nichts“ aus:
| Situation | Was du bekommst |
|---|---|
| Eine text- oder html-Übersetzung wurde geleert | "" für diese Sprache |
| Eine Sprache wurde nie ausgefüllt | Der Sprach-Key fehlt |
| Ein Bild oder Video pro Sprache ist noch nicht hochgeladen | "" für diese Sprache |
| number, color, date, date_range oder boolean nie gesetzt | null |
| Ein Array ohne Einträge | [] |
Die API füllt eine Lücke nie für dich mit einer anderen Sprache. Das entscheidest du, und der Guide Sprachen & Fallbacks zeigt einen sauberen Weg dafür.
Universeller Renderer für alle Blocktypen
Alles von oben, gepackt in eine TypeScript-Datei: Typen für jeden Block, die Helper t() und pickMedia() und ein renderBlock(), das alle zwölf Typen abdeckt. Die drei Snippets unten gehören in eine Datei, lib/diil.tsx. Das ist schlichtes React, läuft also in Next.js, Remix, Vite oder allem anderen, was JSX spricht.
Typen
Block ist eine Discriminated Union über type. Nach if (block.type === 'link') weiß TypeScript, dass block.content.url existiert, und ein vergessener Typ wird zum Compile-Fehler statt zu einer leeren Stelle in Production.
import type { ReactNode } from 'react';
/** Ein String pro Sprachcode: { en: 'Hello', de: 'Hallo' } */
export type LangMap = Record<string, string>;
/** Eine Datei für alle Sprachen */
export type SingleMedia = { file: string; multilang?: never };
/** Eine Datei pro Sprache; '' heißt „noch nicht hochgeladen“ */
export type MultilangMedia = { multilang: true; [lang: string]: string | boolean };
export type Media = SingleMedia | MultilangMedia;
export type LinkValue = { url: string; target: '_self' | '_blank'; title: LangMap };
/** Kommt so zurück, wie im CRM eingegeben: erst eine echte Response loggen, dann darauf verlassen */
export type DateRange = { from?: string; to?: string };
/** Ein Feld in einem Object-Block oder einem Array-Eintrag */
export type FieldValue =
| string | number | boolean | null
| LangMap | Media | LinkValue | DateRange
| ObjectValue | ObjectValue[];
export type ObjectValue = { [field: string]: FieldValue };
type BlockOf<T extends string, C> = {
id: number;
marker: string;
name: string;
type: T;
multilang: boolean;
updatedAt: string;
content: C;
};
export type Block =
| BlockOf<'text', LangMap>
| BlockOf<'html', LangMap>
| BlockOf<'image', Media>
| BlockOf<'video', Media>
| BlockOf<'link', LinkValue>
| BlockOf<'number', number | null>
| BlockOf<'color', string | null>
| BlockOf<'date', string | null>
| BlockOf<'date_range', DateRange | null>
| BlockOf<'boolean', boolean | null>
| BlockOf<'object', ObjectValue>
| BlockOf<'array', ObjectValue[]>;
export type RenderContext = {
lang: string;
/** Der Default der Site: die erste Sprache aus /v1/langs */
fallback?: string;
/** Deine Komponenten für Object- und Array-Blöcke, mit Block-Markern als Keys */
components?: Record<string, (block: Block, ctx: RenderContext) => ReactNode>;
};Helper: t, pickMedia und Shape-Guards
t() wählt eine Übersetzung in fester Reihenfolge: angefragte Sprache, dann Fallback, dann der erste nicht-leere Wert, dann ein leerer String. pickMedia() macht dasselbe für Dateien und versteckt den Unterschied zwischen einer Datei und einer Datei pro Sprache.
/** Angefragte Sprache → Fallback-Sprache → erste nicht-leere Übersetzung → '' */
export function t(map: LangMap | null | undefined, lang: string, fallback?: string): string {
if (!map) return '';
const own = map[lang];
if (own) return own;
const backup = fallback ? map[fallback] : '';
if (backup) return backup;
return Object.values(map).find((value) => value !== '') ?? '';
}
/** URL eines Bilds oder Videos für die Sprache, oder '' */
export function pickMedia(media: Media | null | undefined, lang: string, fallback?: string): string {
if (!media) return '';
if (!media.multilang) return media.file || '';
const files: LangMap = {};
for (const [key, value] of Object.entries(media)) {
if (key !== 'multilang' && typeof value === 'string') files[key] = value;
}
return t(files, lang, fallback);
}
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null && !Array.isArray(value);
/** Formen von Feldern in Objekten erkennen: Die Response hat kein Schema */
export const isLink = (value: unknown): value is LinkValue =>
isRecord(value) && typeof value.url === 'string' && isRecord(value.title);
export const isMedia = (value: unknown): value is Media =>
isRecord(value) && (typeof value.file === 'string' || value.multilang === true);
function formatDate(value: string, lang: string): string {
const date = new Date(value);
// '2026-10-01' wird als Mitternacht UTC geparst, also auch in UTC formatieren,
// sonst sehen Besucher westlich von Greenwich den Vortag
return Number.isNaN(date.getTime()) ? value : date.toLocaleDateString(lang, { timeZone: 'UTC' });
}renderBlock()
export function renderBlock(block: Block, ctx: RenderContext): ReactNode {
const { lang, fallback } = ctx;
switch (block.type) {
case 'text':
return t(block.content, lang, fallback) || null;
case 'html': {
const html = t(block.content, lang, fallback);
return html ? <div dangerouslySetInnerHTML={{ __html: html }} /> : null;
}
case 'image': {
const src = pickMedia(block.content, lang, fallback);
// Alt-Text ist auch Inhalt: Pack ihn in einen Textblock neben dem Bild
return src ? <img src={src} alt="" /> : null;
}
case 'video': {
const src = pickMedia(block.content, lang, fallback);
return src ? <video src={src} controls playsInline /> : null;
}
case 'link': {
const { url, target, title } = block.content;
if (!url) return null;
return (
<a href={url} target={target} rel={target === '_blank' ? 'noopener noreferrer' : undefined}>
{t(title, lang, fallback) || url}
</a>
);
}
case 'number':
return block.content === null ? null : new Intl.NumberFormat(lang).format(block.content);
case 'date':
return block.content ? formatDate(block.content, lang) : null;
case 'date_range': {
const range = block.content;
if (!range?.from) return null;
const from = formatDate(range.from, lang);
return range.to ? from + ' – ' + formatDate(range.to, lang) : from;
}
case 'color':
case 'boolean':
// Einstellungen, kein Text: Lies block.content in deinen Styles und Bedingungen
return null;
case 'object':
case 'array': {
const render = ctx.components?.[block.marker];
return render ? render(block, ctx) : null;
}
default: {
// Zur Compile-Zeit: Jeder Typ ist abgedeckt. Zur Laufzeit: Ein neuer Typ rendert nichts
const unknownBlock: never = block;
void unknownBlock;
return null;
}
}
}Zusammengesetzte Blöcke (object und array) haben kein universelles Aussehen: Eine FAQ und ein Team-Grid haben nichts gemeinsam außer der JSON-Form. Deshalb reicht renderBlock() sie an deine eigenen Komponenten weiter, ausgewählt per Block-Marker — wie die FAQ-Komponente oben.
Alles zusammen
import { renderBlock, type Block, type RenderContext } from '@/lib/diil';
import { Faq } from '@/components/Faq';
export default async function Home({ params }: { params: Promise<{ lang: string }> }) {
const { lang } = await params;
const res = await fetch('https://back.sitecog.com/content/v1/pages/home', {
headers: { 'x-crm-key': process.env.CRM_KEY! },
next: { revalidate: 60 },
});
const page = await res.json();
const hero: Record<string, Block> = page.content.hero.content;
const faq: Record<string, Block> = page.content.faq.content;
const ctx: RenderContext = {
lang,
fallback: 'en', // der Default der Site: die erste Sprache aus /v1/langs
components: { faq_section: Faq },
};
return (
<main>
<h1>{renderBlock(hero.hero_title, ctx)}</h1>
{renderBlock(hero.hero_image, ctx)}
{renderBlock(faq.faq_section, ctx)}
</main>
);
}