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.
{
"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. Único dentro de su sección.namestringtypestringcontent.multilangbooleantrue cuando cada idioma tiene su propio archivo.updatedAtstring (ISO 8601)contentdepends on typeTodos los tipos de bloque de un vistazo
Guárdate esta tabla en favoritos. Responde a “¿qué pinta tiene content?” para cada tipo.
| Tipo | content | Uso 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 |
video | igual que image | Vídeos de fondo y de producto |
link | { "url", "target", "title": { "en": "…" } } | Botones, elementos de menú, llamadas a la acción |
number | 149 o null | Precios, contadores, umbrales |
color | "#3D3D5C" o null | Colores de acento, temas |
date | "2026-10-01" o null | Fechas de eventos, plazos |
date_range | { "from": "…", "to": "…" } o null | Promociones, temporadas |
boolean | true, false o null | Interruptores 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.
{ "en": "Earbuds that mute the city", "de": "Kopfhörer, die die Stadt leise machen" }<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, yt()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.
{
"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) }}
/>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:
{ "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)} />}- Mira
content.multilangpara 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
"", comodearriba. 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,
%20se convierte en%2520y 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.
{ "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 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.
link: enlaces y botones
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.
{
"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>targetes_selfo_blank, así que va directo a la etiqueta.- Añade
rel="noopener noreferrer"a los enlaces_blank: buenos modales y buena seguridad. titlees 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.
149{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.
"#3D3D5C"<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.
"2026-10-01"{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.
{ "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>}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”.
true{(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.
"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: 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.
[
{
"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>- 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ón | Qué 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 valor | null |
| 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.
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.
/** 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()
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
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>
);
}