La API lleva el contenido a tu web. Esta página te quita a los editores de encima. Añade una etiqueta script, espolvorea unos cuantos atributos data-crm-* por tu marcado y quienes mandan sobre los textos podrán hacer clic en un título de la web real, en producción, corregir la errata y guardar: sin ticket, sin deploy y sin el “¿me cambias solo una coma de la home?” un viernes a las seis de la tarde.
Montarlo lleva cuatro pasos, y solo uno requiere pensar:
Añade el script del widget
Una etiqueta<script>en cada página.Deja que el CRM meta tu web en un iframe
Una cabecera de respuesta para que el CRM pueda abrir tu web dentro de su modo Live.Marca los elementos editables
Dile al editor qué elemento muestra qué bloque con los atributosdata-crm-*.Sirve contenido fresco en el modo Live
Sáltate tu caché mientras un editor está mirando, para que los cambios aparezcan al instante.
Qué obtienen los editores
Desde la silla del editor, la edición en vivo se ve así:
- Abren tu web en el modo Live dentro del CRM. Es tu web real, no una maqueta.
- Cada elemento que hayas marcado se enmarca al pasar el ratón. Hacen clic en el que quieren: un título, un párrafo, una imagen.
- Cambian el texto o suben una imagen nueva y le dan a guardar.
- La página se recarga con el contenido nuevo. Listo. Nadie ha abierto un editor de código.
Si un elemento está marcado con un marcador de bloque que todavía no existe en el CRM, el editor puede crear el bloque directamente desde la web. Así que puedes subir primero el marcado y dejar que el equipo de contenido lo rellene después.
Cómo funciona por dentro
Tu página sigue renderizando el contenido de la Content API exactamente igual que antes. Los atributos data-crm-* no renderizan nada: solo conectan un elemento del DOM con un bloque del CRM, como la etiqueta de un cajón.
- El modo Live es un iframe. El CRM carga tu web en un frame y añade
?crm_live=1a la URL. El widget solo carga el editor dentro de ese frame del CRM (cuando el referrer viene del CRM, o la URL lleva?crm_livey el referrer está vacío o es de tu propio sitio); si otra web mete la tuya en su propio iframe, allí no aparece ningún editor. - Un script carga lo que hace falta, y nada más.
widget.jses la única etiqueta que añades. El editor en sí (widget.editor.js) solo se carga cuando la web se abre dentro del modo Live del CRM. El chat de soporte (widget.support.js) solo viene si el chat está activado en el CRM. El inicio de sesión de visitantes (widget.auth.js), solo si la página tiene elementosdata-crm-loginodata-crm-autho un atributodata-crm-key. - Los visitantes no lo pagan. Fuera del CRM no se carga nada del editor: tus visitantes nunca se lo descargan.
- Guardar es una edición normal del CRM. El CRM escribe el bloque, la versión del contenido del sitio sube y la siguiente petición a la API devuelve datos frescos.
- ¿Has puesto la etiqueta dos veces sin querer? No pasa nada: la segunda copia se ignora.
Paso 1. Añade el script del widget
Pon la etiqueta en todas las páginas, justo antes de </body>. Si tu web tiene un layout compartido, ese es el sitio.
<!doctype html>
<html lang="en">
<head>…</head>
<body>
…tu página…
<script src="https://widget.sitecog.com/widget.js" defer></script>
</body>
</html>// app/layout.tsx
import Script from 'next/script';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
{children}
<Script src="https://widget.sitecog.com/widget.js" strategy="afterInteractive" />
</body>
</html>
);
}<!-- index.html en la raíz del proyecto -->
<!doctype html>
<html lang="en">
<head>…</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
<script src="https://widget.sitecog.com/widget.js" defer></script>
</body>
</html>Y eso es todo en cuanto al script. Ni clave, ni llamada de init, ni objeto de configuración: el widget averigua solo si está corriendo dentro del CRM.
Paso 2. Deja que el CRM meta tu web en un iframe
El modo Live muestra tu web en un iframe en https://sitecog.com. Los navegadores solo lo permiten si tu web lo autoriza. Tus respuestas necesitan dos cosas:
- una cabecera
Content-Security-Policyconframe-ancestors 'self' https://sitecog.com; - ninguna cabecera
X-Frame-OptionsconDENYoSAMEORIGIN: se impone a las buenas intenciones y bloquea el frame.
Elige tu servidor:
server {
# …
# Permite que el CRM de Diil abra la web en modo Live
add_header Content-Security-Policy "frame-ancestors 'self' https://sitecog.com" always;
# Borra cualquier línea "add_header X-Frame-Options …" de este bloque server.
# Si la app detrás de proxy_pass pone X-Frame-Options por su cuenta, quítala aquí:
proxy_hide_header X-Frame-Options;
}# .htaccess o la configuración del VirtualHost (necesita mod_headers)
<IfModule mod_headers.c>
Header always set Content-Security-Policy "frame-ancestors 'self' https://sitecog.com"
Header always unset X-Frame-Options
</IfModule>// next.config.ts
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
async headers() {
return [
{
source: '/:path*',
headers: [
{
key: 'Content-Security-Policy',
value: "frame-ancestors 'self' https://sitecog.com",
},
],
},
];
},
};
export default nextConfig;// Antes de tus rutas
app.use((req, res, next) => {
res.removeHeader('X-Frame-Options');
res.setHeader('Content-Security-Policy', "frame-ancestors 'self' https://sitecog.com");
next();
});
// ¿Usas helmet? Por defecto envía X-Frame-Options: SAMEORIGIN. Mejor configúralo así:
// app.use(helmet({
// xFrameOptions: false,
// contentSecurityPolicy: {
// directives: { frameAncestors: ["'self'", 'https://sitecog.com'] },
// },
// }));Paso 3. Marca los elementos editables
Ahora dile al editor qué es cada cosa. Cada atributo dice “este elemento muestra aquel bloque”. El valor es una ruta que empieza por el marcador del bloque que pusiste en el CRM.
Referencia de atributos
| Atributo | Dónde ponerlo | Qué pueden hacer los editores |
|---|---|---|
data-crm-text | Cualquier elemento que muestre texto: h1, p, span, el texto de un botón | Editar el texto de un bloque de texto o de un campo de texto |
data-crm-image | El <img> que muestra un bloque o campo de imagen | Subir o cambiar la imagen |
data-crm-video | El <video> que muestra un bloque o campo de vídeo | Subir o cambiar el vídeo |
data-crm-object | El contenedor que renderiza un bloque object (una sección, una tarjeta) | Ver el grupo de campos como un solo bloque |
data-crm-array | El contenedor que renderiza una lista: un bloque array o un campo de tipo array | Ver la lista como un todo |
Los bloques simples no necesitan más que su marcador:
<section>
<h1 data-crm-text="hero_title">{hero.hero_title.content.en}</h1>
<p data-crm-text="hero_subtitle">{hero.hero_subtitle.content.en}</p>
<img data-crm-image="hero_image" src={hero.hero_image.content.file} alt="" />
<video data-crm-video="hero_video" src={hero.hero_video.content.file} autoPlay muted loop />
</section>Sintaxis de rutas: entrar en objetos y arrays
Los bloques object y array tienen campos dentro, así que la ruta sigue con puntos. El primer segmento siempre es el marcador del bloque. Después vienen los marcadores de los campos del objeto y los índices numéricos del array (empezando por 0).
| Ruta | Apunta a |
|---|---|
hero_title | El bloque hero_title entero |
faq_section.title | El campo title del bloque object faq_section |
faq_section.items | El campo array items |
faq_section.items.0.question | El campo question del primer elemento |
faq_section.items.2.answer | El campo answer del tercer elemento |
Las reglas caben en cuatro líneas:
- los segmentos se separan con puntos; cada uno lleva letras latinas, dígitos y guiones bajos, de 1 a 40 caracteres;
- el primer segmento, el marcador del bloque, tiene al menos 2 caracteres (las reglas de siempre de los marcadores);
- un paso dentro de un objeto es un marcador de campo; un paso dentro de un array es un número;
- los marcadores distinguen mayúsculas:
Hero_titleyhero_titleson dos bloques distintos.
Ejemplo completo: una sección de FAQ
Estos son los datos: un bloque object con un título y un array de preguntas.
"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 the earbuds?" },
"answer": { "en": "Yes, within 14 days." }
}
]
}
}Y este es el marcado. El objeto lleva data-crm-object, la lista lleva data-crm-array y cada texto de dentro lleva la ruta completa con el índice del elemento:
type LangMap = Record<string, string>;
type FaqItem = { question: LangMap; answer: LangMap };
type FaqBlock = { content: { title: LangMap; items: FaqItem[] } };
export function Faq({ block, lang }: { block: FaqBlock; lang: string }) {
const { title, items } = block.content;
return (
<section data-crm-object="faq_section">
<h2 data-crm-text="faq_section.title">{title[lang]}</h2>
<div data-crm-array="faq_section.items">
{items.map((item, i) => (
<details key={i}>
<summary data-crm-text={`faq_section.items.${i}.question`}>
{item.question[lang]}
</summary>
<p data-crm-text={`faq_section.items.${i}.answer`}>
{item.answer[lang]}
</p>
</details>
))}
</div>
</section>
);
}
// Uso: <Faq block={page.content.faq.content.faq_section} lang="en" /><section data-crm-object="faq_section">
<h2 data-crm-text="faq_section.title">FAQ</h2>
<div data-crm-array="faq_section.items">
<details>
<summary data-crm-text="faq_section.items.0.question">How long is delivery?</summary>
<p data-crm-text="faq_section.items.0.answer">1–3 days.</p>
</details>
<details>
<summary data-crm-text="faq_section.items.1.question">Can I return the earbuds?</summary>
<p data-crm-text="faq_section.items.1.answer">Yes, within 14 days.</p>
</details>
</div>
</section>Los arrays dentro de arrays funcionan igual: sigue alternando marcadores de campo e índices, p. ej. pricing.plans.1.features.0.text.
Paso 4. Sirve contenido fresco en el modo Live
Por nuestra parte, cada edición en el CRM llega a la API al momento. Pero tu web puede tener su propia caché: el navegador puede guardar las respuestas de la API hasta 60 segundos, y un revalidate de Next.js las guarda durante su propia ventana. Los visitantes no lo notarán. Un editor que acaba de guardar y sigue viendo el texto viejo, sí.
La solución: cuando la página está abierta en el modo Live, haz el fetch con cache: 'no-store'. El modo Live se reconoce por el parámetro crm_live de la URL o porque la página se ejecuta dentro de un iframe. Nuestra propia web de referencia hace exactamente esto:
// crm.ts — ¿está la página abierta en el modo Live del CRM?
export function isCrmLive(): boolean {
if (typeof window === 'undefined') return false;
try {
if (new URLSearchParams(window.location.search).has('crm_live')) return true;
// El parámetro se puede perder tras un enlace interno: la comprobación del iframe lo cubre
return window.parent !== window;
} catch {
return false;
}
}
export async function getPage(marker: string) {
const res = await fetch(`https://back.sitecog.com/content/v1/pages/${marker}`, {
headers: { 'x-crm-key': 'pk_3f9c2a7e1b4d4c0e8a6f5b2d9c1e7a40' },
// Los editores siempre reciben contenido fresco; los visitantes, el rápido de la caché
cache: isCrmLive() ? 'no-store' : 'default',
});
if (!res.ok) throw new Error(`Content API: ${res.status}`);
return res.json();
}// app/page.tsx — un Server Component solo ve la URL, así que comprueba crm_live
type Props = { searchParams: Promise<Record<string, string | string[] | undefined>> };
export default async function Home({ searchParams }: Props) {
const live = 'crm_live' in (await searchParams);
const res = await fetch('https://back.sitecog.com/content/v1/pages/home?lang=en', {
headers: { 'x-crm-key': process.env.CRM_KEY! },
// Modo Live: directo de la API. Para el resto: caché de 60 segundos
...(live ? { cache: 'no-store' as const } : { next: { revalidate: 60 } }),
});
const page = await res.json();
const hero = page.content.hero.content;
return <h1 data-crm-text="hero_title">{hero.hero_title.content.en}</h1>;
}Qué se cachea dónde y durante cuánto tiempo lo tienes en la página Caché y ETag.
Checklist
- La etiqueta
widget.jsestá en todas las páginas, antes de</body>. - Las respuestas llevan
frame-ancestors 'self' https://sitecog.com. - No hay ninguna cabecera
X-Frame-Optionsen ningún sitio: revisa también el panel del hosting, el CDN y los valores por defecto del framework. - Los elementos editables tienen atributos
data-crm-*y los marcadores coinciden con el CRM letra por letra. - Los objetos y las listas están envueltos en
data-crm-object/data-crm-arrayy las rutas internas usan los índices correctos. - En el modo Live la web pide el contenido con
cache: 'no-store'. - Has abierto la web en el modo Live, has hecho clic en un título, lo has cambiado y has visto el cambio. 🎉
Solución de problemas
“El sitio no permite incrustarlo”
El CRM intentó abrir tu web en un frame y el navegador dijo que no. Los sospechosos habituales:
- falta la directiva
frame-ancestors, ohttps://sitecog.comno está en ella; - algo sigue enviando
X-Frame-Options: un panel de hosting, un CDN, un plugin de seguridad, helmet en Express; - la CSP se pone con
<meta>en vez de con una cabecera, así queframe-ancestorsse ignora; - la cabecera está configurada para un host, pero la web se abre en otro (con o sin
www).
Comprueba qué envía tu servidor de verdad:
curl -sI https://your-site.com | grep -iE "content-security-policy|x-frame-options"Un elemento no se puede pulsar en el modo Live
- El atributo no está en el HTML renderizado. Inspecciona la página en DevTools, no el código fuente: algunos componentes no pasan las props desconocidas al DOM.
- La ruta está mal formada: un espacio, un guion, una letra no latina, un punto al final. El widget escribe un aviso sobre el formato del marcador en la consola del navegador.
- Solo está marcado el contenedor.
data-crm-objectydata-crm-arrayagrupan cosas; las partes pulsables son los textos y las imágenes de dentro, y necesitan su propiodata-crm-text/data-crm-image. - El script del widget no está en esta página en concreto: es fácil que se escape cuando la web tiene varios layouts.
Guardado, pero el cambio no se ve
- Tu fetch está en caché. Usa
cache: 'no-store'en el modo Live (paso 4). - La página es totalmente estática (se genera una vez en el deploy), así que no puede enterarse del contenido nuevo hasta el siguiente build. Haz que pida los datos en cada petición, al menos en el modo Live.
- El elemento muestra un texto hardcodeado o un fallback en vez del valor de la API: el atributo está, los datos no.
- La ruta apunta a un sitio distinto de lo que se renderiza; p. ej., la etiqueta muestra el elemento
1pero está marcado comoitems.0. - La clave del sitio es de otro sitio, o la página renderiza un idioma distinto del que se está editando. Mira Idiomas y fallbacks.
El widget sabe hacer más
La misma etiqueta widget.js cuenta visitas, envía tus propios eventos con window.crmTrack(name, params), convierte los formularios form[data-crm-lead] en leads del CRM y muestra un chat de soporte en vivo. Sin scripts extra — elige lo que necesites: