A block is the smallest piece of content an editor can change: a headline, a price, a hero photo, a whole FAQ. There are twelve block types, and the type field tells you exactly what shape content will have. Learn the twelve shapes once and you can render any Diil site without guessing.
Every block has the same envelope
Whichever endpoint hands you a block — a page, a section or a single block — it always comes in the same wrapper. Only content changes from type to type.
{
"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. Unique within its section.namestringtypestringcontent.multilangbooleantrue when every language has its own file.updatedAtstring (ISO 8601)contentdepends on typeAll block types at a glance
Bookmark this table. It answers “what does content look like?” for every type.
| Type | content | Typical use |
|---|---|---|
text | { "en": "…", "de": "…" } | Headlines, buttons, short copy |
html | { "en": "<p>…</p>" } | Formatted text: paragraphs, lists, links |
image | { "file": "https://…" } or { "multilang": true, "en": "…" } | Photos, banners, logos |
video | same as image | Background and product videos |
link | { "url", "target", "title": { "en": "…" } } | Buttons, menu items, calls to action |
number | 149 or null | Prices, counters, thresholds |
color | "#3D3D5C" or null | Accent colours, themes |
date | "2026-10-01" or null | Event dates, deadlines |
date_range | { "from": "…", "to": "…" } or null | Promotions, seasons |
boolean | true, false or null | Show/hide switches |
object | { "field": value, … } | A group of fields: a card, an FAQ, a contact box |
array | [ { "field": value, … }, … ] | Repeating items: team, reviews, features |
The examples below use two tiny helpers: t() for language maps and pickMedia() for files. Both live in the universal renderer at the end of the page, ready to copy.
text — plain text
In the CRM the editor types plain text, one value per language. No formatting, no surprises.
{ "en": "Earbuds that mute the city", "de": "Kopfhörer, die die Stadt leise machen" }<h1>{t(block.content, lang, defaultLang)}</h1>- The keys are language codes in the order set in the CRM, not in the order you asked for them.
- A translation the editor cleared comes back as
"". A language that was never filled in is simply absent. Your code has to survive both —t()does. - React escapes text for you, so a stray
<in a headline is harmless.
html — formatted text
In the CRM the editor gets formatted text — bold, lists, links and so on. You receive the resulting HTML, one string per language.
{
"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) }}
/>Style the inner tags from your CSS (a .prose class or similar). The editor decides what is bold, you decide how bold looks.
image — pictures
In the CRM the editor uploads a picture: either one file for every language or, when the block is multilingual, a separate file per language (handy for banners with text baked into the image). That choice changes the shape of 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)} />}- Check
content.multilangto tell the two shapes apart. It travels with the value, so it also works inside objects and arrays, where there is no block envelope. - A language whose file has not been uploaded yet is
"", likedeabove. Fall back to another language instead of rendering<img src="">. - URLs arrive already escaped (spaces and friends). Use them as they are: encoding them again turns
%20into%2520and the picture into a broken icon. - An image block has no alt text. Keep it in a text block next to the image, so editors can translate it.
video — video files
In the CRM the editor uploads a video file. Everything said about images applies here too: the same two shapes, the same empty strings, the same ready-to-use 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 />}It is a file, not an embed code, so a plain <video> tag is all you need. Background videos need muted and playsInline, otherwise mobile browsers refuse to autoplay them.
link — links and buttons
In the CRM the editor sets the address, whether it opens in the same tab or a new one, and the link text for each language.
{
"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>targetis either_selfor_blank, so it goes straight into the tag.- Add
rel="noopener noreferrer"to_blanklinks — good manners and good security. titleis a language map with the same rules as a text block: an empty string or a missing language is possible.
Raw values: number, color, date, date_range, boolean
These five are not translated, so there is no language map: content is the value itself, exactly as stored. If the editor has not set anything yet, you get null.
number
In the CRM: a number field. Prices, “years on the market”, the free delivery threshold.
149{block.content !== null && (
<span className="price">{new Intl.NumberFormat(lang).format(block.content)}</span>
)}color
In the CRM: a colour. An accent colour, the background of a promo strip.
"#3D3D5C"<section style={{ background: block.content ?? '#ffffff' }}>…</section>A colour is a setting, not something to print. Feed it to style or a CSS variable and always keep a default for null.
date
In the CRM: a date. When the event starts, when the offer ends.
"2026-10-01"{block.content && (
<time dateTime={block.content}>
{new Date(block.content).toLocaleDateString(lang, { timeZone: 'UTC' })}
</time>
)}The value comes back as entered in the CRM, so log one real response before you hard-code a format. And a classic trap: new Date('2026-10-01') is midnight UTC, so format it with timeZone: 'UTC', or visitors west of Greenwich will see September 30.
date_range
In the CRM: a start and an end date. A sale week, a summer season.
{ "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>}Same rule as for date: the value is stored as entered in the CRM, so check a live response and code defensively.
boolean
In the CRM: an on/off switch. “Show the sale banner”, “We are taking orders”.
true{(block.content ?? false) && <SaleBanner />}Three states, not two: true, false and null (never set). Decide what null means on your site and spell it out with ??.
object — a group of fields
In the CRM an object block looks like a small form: several named fields that belong together — a contact card, a pricing tier, an FAQ with a title and a list of questions.
content is an object keyed by field markers. Each field follows the rules of its own type: text fields are language maps, link fields are { url, target, title }, image fields are { file } or { multilang, … }, everything else is a raw value. A field can even be a list of items.
"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 — a list of items
In the CRM an array is a list of repeating items with the same fields: team members, reviews, feature cards. Editors add and remove items, and you get them in the order shown in the CRM.
content is a plain JSON array. Every item is shaped exactly like the content of an object block: fields by 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>- An item is just its fields, so use the index as the React key (or a field you know is unique).
- An empty list is
[]. Decide whether the whole section should disappear when there is nothing to show. - Want editors to change list items right on the site? Mark the list up with
data-crm-array— see Live editing.
Empty values cheat sheet
The number one reason for “why is there a blank space on the page?”. Here is what “nothing” looks like:
| Situation | What you get |
|---|---|
| A text or html translation was cleared | "" for that language |
| A language was never filled in | The language key is missing |
| A per-language image or video is not uploaded yet | "" for that language |
| number, color, date, date_range or boolean never set | null |
| An array with no items | [] |
The API never fills a gap with another language for you. That is your call, and the Languages & fallbacks guide shows a clean way to make it.
Universal renderer for all block types
Everything above, packed into one TypeScript file: types for every block, the t() and pickMedia() helpers and a renderBlock() that handles all twelve types. The three snippets below go into one file, lib/diil.tsx. It is plain React, so it works in Next.js, Remix, Vite or anything else that speaks JSX.
Types
Block is a discriminated union on type. After if (block.type === 'link') TypeScript knows that block.content.url exists, and a forgotten type becomes a compile error instead of a blank spot in production.
import type { ReactNode } from 'react';
/** One string per language code: { en: 'Hello', de: 'Hallo' } */
export type LangMap = Record<string, string>;
/** One file for every language */
export type SingleMedia = { file: string; multilang?: never };
/** A file per language; '' means "not uploaded yet" */
export type MultilangMedia = { multilang: true; [lang: string]: string | boolean };
export type Media = SingleMedia | MultilangMedia;
export type LinkValue = { url: string; target: '_self' | '_blank'; title: LangMap };
/** Comes back as entered in the CRM: log a real response before relying on it */
export type DateRange = { from?: string; to?: string };
/** A field inside an object block or an array item */
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;
/** The site default: the first language from /v1/langs */
fallback?: string;
/** Your components for object and array blocks, keyed by block marker */
components?: Record<string, (block: Block, ctx: RenderContext) => ReactNode>;
};Helpers: t, pickMedia and shape guards
t() picks a translation in a fixed order: the requested language, then the fallback, then the first non-empty value, then an empty string. pickMedia() does the same for files and hides the difference between one file and a file per language.
/** Requested language → fallback language → first non-empty translation → '' */
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 of an image or video for the language, or '' */
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);
/** Shape sniffing for fields inside objects: the response has no 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' is parsed as UTC midnight, so format in UTC too,
// otherwise visitors west of Greenwich see the previous day
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 is content too: keep it in a text block next to the image
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':
// Settings, not text: read block.content in your styles and conditions
return null;
case 'object':
case 'array': {
const render = ctx.components?.[block.marker];
return render ? render(block, ctx) : null;
}
default: {
// Compile time: every type is handled. Run time: a new type renders nothing
const unknownBlock: never = block;
void unknownBlock;
return null;
}
}
}Composite blocks (object and array) have no universal look: an FAQ and a team grid share nothing but the JSON shape. So renderBlock() hands them over to your own components, picked by block marker — like the FAQ component above.
Putting it all together
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', // the site default: the first language from /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>
);
}