Diil Docs
  1. Documentación
  2. Guías

Tipos de bloque y formato de su contenido

Actualizado:

Un bloque es la pieza de contenido más pequeña que puede cambiar un editor: un titular, un precio, la foto del hero, unas FAQ enteras. Hay doce tipos de bloque, y el campo type te dice exactamente qué forma tendrá content. Apréndete las doce formas una vez y podrás renderizar cualquier sitio de Diil sin ir a ciegas.

Todos los bloques vienen en el mismo sobre

Te dé el bloque el endpoint que te lo dé (una página, una sección o un bloque suelto), siempre llega con el mismo envoltorio. Solo content cambia de un tipo a otro.

Un bloquejson
{
  "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
Id interno del bloque. En tu código usa el marcador: los ids cambian de un entorno a otro.
markerstring
El marcador del bloque, p. ej. hero_title. Único dentro de su sección.
namestring
Nombre del CRM pensado para los editores. Es para las personas del panel de administración, no para tus visitantes.
typestring
Uno de los doce tipos de abajo. Determina la forma de content.
multilangboolean
Para imágenes y vídeos: true cuando cada idioma tiene su propio archivo.
updatedAtstring (ISO 8601)
Cuándo se editó el bloque por última vez.
contentdepends on type
El valor en sí. El resto de esta página trata solo de este campo.

Todos los tipos de bloque de un vistazo

Guárdate esta tabla en favoritos. Responde a “¿qué pinta tiene content?” para cada tipo.

TipocontentUso típico
text{ "en": "…", "de": "…" }Titulares, botones, textos cortos
html{ "en": "<p>…</p>" }Texto con formato: párrafos, listas, enlaces
image{ "file": "https://…" } o { "multilang": true, "en": "…" }Fotos, banners, logos
videoigual que imageVídeos de fondo y de producto
link{ "url", "target", "title": { "en": "…" } }Botones, elementos de menú, llamadas a la acción
number149 o nullPrecios, contadores, umbrales
color"#3D3D5C" o nullColores de acento, temas
date"2026-10-01" o nullFechas de eventos, plazos
date_range{ "from": "…", "to": "…" } o nullPromociones, temporadas
booleantrue, false o nullInterruptores de mostrar/ocultar
object{ "field": value, … }Un grupo de campos: una tarjeta, unas FAQ, un recuadro de contacto
array[ { "field": value, … }, … ]Elementos que se repiten: equipo, opiniones, características

Los ejemplos de abajo usan dos helpers diminutos: t() para los mapas de idiomas y pickMedia() para los archivos. Los dos viven en el renderizador universal del final de la página, listos para copiar.

text: texto plano

En el CRM el editor escribe texto plano, un valor por idioma. Sin formato, sin sorpresas.

contentjson
{ "en": "Earbuds that mute the city", "de": "Kopfhörer, die die Stadt leise machen" }
Rendertsx
<h1>{t(block.content, lang, defaultLang)}</h1>
  • Las claves son códigos de idioma en el orden fijado en el CRM, no en el orden en que los pediste.
  • Una traducción que el editor ha vaciado llega como "". Un idioma que nunca se rellenó simplemente no aparece. Tu código tiene que sobrevivir a las dos cosas, y t() lo hace.
  • React escapa el texto por ti, así que un < perdido en un titular no hace daño.

html: texto con formato

En el CRM el editor tiene texto con formato: negrita, listas, enlaces, etc. Tú recibes el HTML resultante, una cadena por idioma.

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) }}
/>

Dale estilo a las etiquetas de dentro desde tu CSS (una clase .prose o similar). El editor decide qué va en negrita; tú decides cómo se ve la negrita.

image: imágenes

En el CRM el editor sube una imagen: o un solo archivo para todos los idiomas o, cuando el bloque es multidioma, un archivo distinto por idioma (muy útil para banners con el texto metido en la imagen). Esa elección cambia la forma de content:

Un archivo para todos los idiomasjson
{ "file": "https://cdn.example.com/storage/your-site/hero.jpg" }
Un archivo por idioma (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)} />}
  • Mira content.multilang para distinguir las dos formas. Viaja con el valor, así que también funciona dentro de objetos y arrays, donde no hay sobre de bloque.
  • Un idioma cuyo archivo aún no se ha subido es "", como de arriba. Tira de otro idioma en vez de renderizar <img src="">.
  • Las URL llegan ya escapadas (espacios y compañía). Úsalas tal cual: si las vuelves a codificar, %20 se convierte en %2520 y la imagen, en un icono roto.
  • Un bloque de imagen no tiene texto alternativo. Guárdalo en un bloque de texto junto a la imagen para que los editores puedan traducirlo.

video: archivos de vídeo

En el CRM el editor sube un archivo de vídeo. Todo lo dicho sobre las imágenes vale también aquí: las mismas dos formas, las mismas cadenas vacías, las mismas URL listas para usar.

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 />}

Es un archivo, no un código de inserción, así que te basta con una etiqueta <video> normal. Los vídeos de fondo necesitan muted y playsInline; si no, los navegadores móviles se niegan a reproducirlos solos.

En el CRM el editor pone la dirección, si se abre en la misma pestaña o en una nueva y el texto del enlace para cada idioma.

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 es _self o _blank, así que va directo a la etiqueta.
  • Añade rel="noopener noreferrer" a los enlaces _blank: buenos modales y buena seguridad.
  • title es un mapa de idiomas con las mismas reglas que un bloque de texto: puede haber una cadena vacía o faltar un idioma.

Valores en crudo: number, color, date, date_range, boolean

Estos cinco no se traducen, así que no hay mapa de idiomas: content es el valor en sí, tal cual está guardado. Si el editor todavía no ha puesto nada, recibes null.

number

En el CRM: un campo numérico. Precios, “años en el mercado”, el umbral del envío gratis.

contentjson
149
Rendertsx
{block.content !== null && (
  <span className="price">{new Intl.NumberFormat(lang).format(block.content)}</span>
)}

color

En el CRM: un color. Un color de acento, el fondo de una franja promocional.

contentjson
"#3D3D5C"
Rendertsx
<section style={{ background: block.content ?? '#ffffff' }}>…</section>

Un color es un ajuste, no algo que imprimir. Pásalo a style o a una variable CSS y ten siempre un valor por defecto para null.

date

En el CRM: una fecha. Cuándo empieza el evento, cuándo acaba la oferta.

contentjson
"2026-10-01"
Rendertsx
{block.content && (
  <time dateTime={block.content}>
    {new Date(block.content).toLocaleDateString(lang, { timeZone: 'UTC' })}
  </time>
)}

El valor llega tal como se introdujo en el CRM, así que registra una respuesta real antes de hardcodear un formato. Y una trampa clásica: new Date('2026-10-01') es medianoche UTC, así que formatéalo con timeZone: 'UTC' o los visitantes al oeste de Greenwich verán el 30 de septiembre.

date_range

En el CRM: una fecha de inicio y una de fin. Una semana de rebajas, la temporada de verano.

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>}

La misma regla que para date: el valor se guarda tal como se introdujo en el CRM, así que mira una respuesta real y programa a la defensiva.

boolean

En el CRM: un interruptor de encendido/apagado. “Mostrar el banner de rebajas”, “Aceptamos pedidos”.

contentjson
true
Rendertsx
{(block.content ?? false) && <SaleBanner />}

Tres estados, no dos: true, false y null (nunca se ha puesto). Decide qué significa null en tu web y déjalo claro con ??.

object: un grupo de campos

En el CRM un bloque object parece un formulario pequeño: varios campos con nombre que van juntos, como una tarjeta de contacto, un plan de precios o unas FAQ con un título y una lista de preguntas.

content es un objeto indexado por marcadores de campo. Cada campo sigue las reglas de su propio tipo: los campos de texto son mapas de idiomas, los de enlace son { url, target, title }, los de imagen son { file } o { multilang, … } y todo lo demás es un valor en crudo. Un campo puede ser incluso una lista de elementos.

Unas FAQ como bloque objectjson
"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: una lista de elementos

En el CRM un array es una lista de elementos repetidos con los mismos campos: miembros del equipo, opiniones, tarjetas de características. Los editores añaden y quitan elementos, y tú los recibes en el orden que se ve en el CRM.

content es un array JSON normal y corriente. Cada elemento tiene exactamente la misma forma que el contenido de un bloque object: campos por marcador.

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>
  • Un elemento son solo sus campos, así que usa el índice como key de React (o un campo que sepas que es único).
  • Una lista vacía es []. Decide si toda la sección debe desaparecer cuando no hay nada que mostrar.
  • ¿Quieres que los editores cambien los elementos de la lista directamente en la web? Marca la lista con data-crm-array: mira Edición en vivo.

Chuleta de valores vacíos

El motivo número uno de “¿por qué hay un hueco en blanco en la página?”. Así es como se ve la “nada”:

SituaciónQué recibes
Se vació una traducción de text o html"" para ese idioma
Un idioma nunca se rellenóFalta la clave del idioma
Aún no se ha subido la imagen o el vídeo de un idioma"" para ese idioma
number, color, date, date_range o boolean sin valornull
Un array sin elementos[]

La API nunca rellena un hueco con otro idioma por ti. Eso lo decides tú, y la guía Idiomas y fallbacks enseña una forma limpia de hacerlo.

Renderizador universal para todos los tipos de bloque

Todo lo anterior, empaquetado en un archivo TypeScript: tipos para cada bloque, los helpers t() y pickMedia() y un renderBlock() que se encarga de los doce tipos. Los tres fragmentos de abajo van en un solo archivo, lib/diil.tsx. Es React puro, así que funciona en Next.js, Remix, Vite o cualquier otra cosa que hable JSX.

Tipos

Block es una unión discriminada por type. Después de if (block.type === 'link') TypeScript sabe que block.content.url existe, y un tipo olvidado se convierte en un error de compilación en vez de en un hueco en blanco en producción.

lib/diil.tsx — tiposts
import type { ReactNode } from 'react';

/** Una cadena por código de idioma: { en: 'Hello', de: 'Hallo' } */
export type LangMap = Record<string, string>;

/** Un archivo para todos los idiomas */
export type SingleMedia = { file: string; multilang?: never };

/** Un archivo por idioma; '' significa "aún no subido" */
export type MultilangMedia = { multilang: true; [lang: string]: string | boolean };

export type Media = SingleMedia | MultilangMedia;

export type LinkValue = { url: string; target: '_self' | '_blank'; title: LangMap };

/** Llega tal como se introdujo en el CRM: registra una respuesta real antes de fiarte */
export type DateRange = { from?: string; to?: string };

/** Un campo dentro de un bloque object o de un elemento de array */
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;
  /** El idioma por defecto del sitio: el primero de /v1/langs */
  fallback?: string;
  /** Tus componentes para los bloques object y array, indexados por marcador de bloque */
  components?: Record<string, (block: Block, ctx: RenderContext) => ReactNode>;
};

Helpers: t, pickMedia y guards de forma

t() elige una traducción en un orden fijo: el idioma pedido, luego el fallback, luego el primer valor no vacío y, por último, una cadena vacía. pickMedia() hace lo mismo con los archivos y oculta la diferencia entre un solo archivo y un archivo por idioma.

lib/diil.tsx — helpersts
/** Idioma pedido → idioma de fallback → primera traducción no vacía → '' */
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 de una imagen o vídeo para el idioma, o '' */
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);

/** Detectar la forma de los campos dentro de objetos: la respuesta no trae esquema */
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' se interpreta como medianoche UTC, así que formateamos también en UTC;
  // si no, los visitantes al oeste de Greenwich ven el día anterior
  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);
      // el texto alternativo también es contenido: guárdalo en un bloque de texto junto a la imagen
      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':
      // Son ajustes, no texto: lee block.content en tus estilos y condiciones
      return null;

    case 'object':
    case 'array': {
      const render = ctx.components?.[block.marker];
      return render ? render(block, ctx) : null;
    }

    default: {
      // En compilación: todos los tipos están cubiertos. En ejecución: un tipo nuevo no renderiza nada
      const unknownBlock: never = block;
      void unknownBlock;
      return null;
    }
  }
}

Los bloques compuestos (object y array) no tienen un aspecto universal: unas FAQ y una cuadrícula del equipo no comparten nada salvo la forma del JSON. Por eso renderBlock() se los pasa a tus propios componentes, elegidos por marcador de bloque, como el componente de FAQ de arriba.

Todo junto

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', // el idioma por defecto del sitio: el primero de /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>
  );
}