Diil Docs
  1. Docs
  2. Guides

Block types and their content format

Updated:

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.

A blockjson
{
  "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" }
}
idnumber
Internal block id. Use the marker in your code — ids differ between environments.
markerstring
The block marker, e.g. hero_title. Unique within its section.
namestring
Editor-facing name from the CRM. For humans in the admin panel, not for your visitors.
typestring
One of the twelve types below. Decides the shape of content.
multilangboolean
For images and videos: true when every language has its own file.
updatedAtstring (ISO 8601)
When the block was last edited.
contentdepends on type
The value itself. The rest of this page is about this one field.

All block types at a glance

Bookmark this table. It answers “what does content look like?” for every type.

TypecontentTypical 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
videosame as imageBackground and product videos
link{ "url", "target", "title": { "en": "…" } }Buttons, menu items, calls to action
number149 or nullPrices, counters, thresholds
color"#3D3D5C" or nullAccent colours, themes
date"2026-10-01" or nullEvent dates, deadlines
date_range{ "from": "…", "to": "…" } or nullPromotions, seasons
booleantrue, false or nullShow/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.

contentjson
{ "en": "Earbuds that mute the city", "de": "Kopfhörer, die die Stadt leise machen" }
Rendertsx
<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.

contentjson
{
  "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>"
}
Rendertsx
<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:

One file for every languagejson
{ "file": "https://cdn.example.com/storage/your-site/hero.jpg" }
A file per language (multilang)json
{
  "multilang": true,
  "en": "https://cdn.example.com/storage/your-site/banner-en.jpg",
  "de": ""
}
Rendertsx
const src = pickMedia(block.content, lang, defaultLang);

{src && <img src={src} alt={t(altBlock.content, lang, defaultLang)} />}
  • Check content.multilang to 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 "", like de above. 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 %20 into %2520 and 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.

contentjson
{ "file": "https://cdn.example.com/storage/your-site/promo.mp4" }
Rendertsx
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.

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.

contentjson
{
  "url": "https://example.com/pricing",
  "target": "_blank",
  "title": { "en": "See pricing", "de": "Preise ansehen" }
}
Rendertsx
const { url, target, title } = block.content;

<a
  href={url}
  target={target}
  rel={target === '_blank' ? 'noopener noreferrer' : undefined}
>
  {t(title, lang, defaultLang)}
</a>
  • target is either _self or _blank, so it goes straight into the tag.
  • Add rel="noopener noreferrer" to _blank links — good manners and good security.
  • title is 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.

contentjson
149
Rendertsx
{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.

contentjson
"#3D3D5C"
Rendertsx
<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.

contentjson
"2026-10-01"
Rendertsx
{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.

contentjson
{ "from": "2026-10-01", "to": "2026-10-07" }
Rendertsx
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”.

contentjson
true
Rendertsx
{(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.

An FAQ as an object blockjson
"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." } }
    ]
  }
}
components/Faq.tsxtsx
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.

contentjson
[
  {
    "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" }
  }
]
Rendertsx
<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:

SituationWhat you get
A text or html translation was cleared"" for that language
A language was never filled inThe 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 setnull
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.

lib/diil.tsx — typests
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.

lib/diil.tsx — helpersts
/** 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()

lib/diil.tsx — renderBlocktsx
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

app/[lang]/page.tsxtsx
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>
  );
}