Las APIs de contenido suelen obligarte a elegir: rápido o fresco. Nosotros preferimos no elegir. Las respuestas se cachean en nuestro lado pero allí nunca se quedan viejas, cada respuesta lleva un ETag para que el contenido sin cambios no te cueste casi nada, y un cambio en el CRM llega a la API en cuanto el editor pulsa “Guardar”. Esta página explica cómo funciona y cómo sacarle el máximo partido en tu lado.
De un cambio en el CRM a tu página
Este es el viaje completo de un cambio, del teclado del editor a la pantalla de tu visitante:
El editor guarda
Alguien corrige una errata en el CRM o directamente en la web en vivo. Vale cualquier cambio: un texto, una imagen, un ajuste de la página, un post del blog.Sube la versión del contenido del sitio
Cada cambio incrementa la versión del contenido del sitio. Nuestra caché del servidor (Redis) va atada a esa versión, así que todas las respuestas cacheadas antiguas dejan de servir de golpe. Nadie tiene que “limpiar la caché”.La siguiente petición a la API recibe datos frescos
La primerísima petición a la API construye su respuesta con el contenido nuevo. En nuestro lado no hay ningún retraso: ni TTL que esperar ni cola de purgado.Las cachés entre nosotros y el visitante se ponen al día
Lo que ve un visitante de verdad puede ir un poco por detrás: el navegador o una CDN pueden guardar la respuesta anterior hasta 60 segundos y mostrarla una vez más mientras traen la nueva en segundo plano (eso esstale-while-revalidate). Tu propia caché de servidor, si tienes, suma su propio tiempo de vida encima.
Las cabeceras de caché, explicadas
Una respuesta correcta típica llega con estas cabeceras:
HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: public, max-age=60, stale-while-revalidate=600
ETag: W/"a41f9c0e7b2d58f3"
Vary: x-crm-key, Accept-Encoding
Access-Control-Allow-Origin: *| Cabecera | Qué les dice a las cachés |
|---|---|
Cache-Control: public | Cualquiera puede guardar la respuesta: el navegador, una CDN, un proxy. El contenido es público de todos modos: es lo que muestras en tu web. |
max-age=60 | Durante 60 segundos la respuesta cuenta como fresca y se puede reutilizar sin preguntarnos nada. |
stale-while-revalidate=600 | Durante los 10 minutos siguientes una caché puede entregar la copia vieja al instante mientras trae una nueva en segundo plano. Rápido para este visitante, fresco para el siguiente. |
ETag: W/"…" | Un ETag débil: un hash del cuerpo de la respuesta. Mismo cuerpo, mismo ETag. Devuélvelo en If-None-Match y recibirás un 304 si nada ha cambiado. |
Vary: x-crm-key, Accept-Encoding | Las cachés deben guardar copias separadas por clave de sitio y por compresión. Dos sitios nunca comparten una respuesta cacheada, ni siquiera con la misma URL. |
Cache-Control: no-store | Se envía con cada error. Un 404 de una página que un editor está creando ahora mismo no debería quedarse rondando en la caché de nadie. |
CORS está abierto a cualquier origen, la API permite explícitamente la cabecera de petición If-None-Match y expone ETag a JavaScript, así que todo lo de esta página funciona también desde el navegador.
ETag y 304 Not Modified
El ETag es la forma más barata de preguntar “¿ha cambiado algo?”. Guarda el ETag de la última respuesta, envíalo en If-None-Match la próxima vez y, si el contenido es el mismo, recibirás 304 Not Modified con el cuerpo vacío. Tu código sigue usando la copia que ya tiene.
GET /content/v1/pages/home?lang=en HTTP/1.1
Host: back.sitecog.com
x-crm-key: pk_3f9c2a7e1b4d4c0e8a6f5b2d9c1e7a40
HTTP/1.1 200 OK
Cache-Control: public, max-age=60, stale-while-revalidate=600
ETag: W/"a41f9c0e7b2d58f3"
Vary: x-crm-key, Accept-Encoding
{ "id": 26, "marker": "home", "name": "Home", "content": { … } }GET /content/v1/pages/home?lang=en HTTP/1.1
Host: back.sitecog.com
x-crm-key: pk_3f9c2a7e1b4d4c0e8a6f5b2d9c1e7a40
If-None-Match: W/"a41f9c0e7b2d58f3"
HTTP/1.1 304 Not Modified
Cache-Control: public, max-age=60, stale-while-revalidate=600
ETag: W/"a41f9c0e7b2d58f3"En cuanto un editor cambia algo en esa página, cambia el cuerpo, cambia el hash y la misma petición devuelve un 200 fresco con un ETag nuevo.
Recetas de caché
En el navegador: ya está hecho
Si pides el contenido directamente en el navegador, no necesitas ni una línea de código de caché. Un fetch normal usa la caché HTTP del navegador: durante 60 segundos reutiliza la respuesta y después revalida con If-None-Match él solito.
const res = await fetch('https://back.sitecog.com/content/v1/pages/home?lang=en', {
headers: { 'x-crm-key': 'pk_3f9c2a7e1b4d4c0e8a6f5b2d9c1e7a40' },
// cache: 'default' es el valor por defecto: el navegador gestiona max-age y ETag por ti
});
const page = await res.json();No te sorprendas si tu código nunca ve un 304: el navegador lo cambia por el 200 cacheado antes de que te llegue. Abre la pestaña Network de DevTools para ver qué ha pasado de verdad: ahí encontrarás el 304 o el “disk cache”.
En un servidor Node: una caché con ETag minúscula
El fetch del lado del servidor en Node no tiene caché HTTP propia, así que cada llamada llega hasta la API. Un pequeño Map lo arregla: reutiliza la copia durante 60 segundos (igual que max-age), luego pregunta con If-None-Match y descarga el cuerpo solo si ha cambiado.
const API = 'https://back.sitecog.com/content';
const TTL = 60_000; // igual que max-age=60
type Entry = { etag: string | null; data: unknown; at: number };
// Un proceso, una clave de sitio. ¿Usas varias claves? Mete también la clave en la clave de caché.
const cache = new Map<string, Entry>();
export async function getContent<T>(path: string): Promise<T> {
const url = API + path;
const cached = cache.get(url);
// 1. Todavía fresco: ni siquiera llamamos a la API
if (cached && Date.now() - cached.at < TTL) return cached.data as T;
// 2. Preguntamos "¿ha cambiado?" con el ETag que ya tenemos
const headers: Record<string, string> = { 'x-crm-key': process.env.CRM_KEY! };
if (cached?.etag) headers['if-none-match'] = cached.etag;
const res = await fetch(url, { headers });
if (res.status === 304 && cached) {
cached.at = Date.now(); // mismo contenido, otros 60 segundos de paz
return cached.data as T;
}
if (!res.ok) throw new Error(`Content API ${res.status} for ${path}`);
const data = (await res.json()) as T;
cache.set(url, { etag: res.headers.get('etag'), data, at: Date.now() });
return data;
}
// uso
const home = await getContent('/v1/pages/home?lang=en');Next.js: revalidate y refresco bajo demanda
En el App Router la caché de fetch hace el trabajo por ti. revalidate: 60 coincide con nuestro max-age: Next.js guarda la respuesta un minuto y luego la refresca en segundo plano.
export default async function Home() {
const res = await fetch('https://back.sitecog.com/content/v1/pages/home?lang=en', {
headers: { 'x-crm-key': process.env.CRM_KEY! },
next: { revalidate: 60, tags: ['crm-content'] },
});
const page = await res.json();
return <h1>{page.content.hero.content.hero_title.content.en}</h1>;
}¿Quieres menos retraso? Baja revalidate, pero vigila los límites. ¿Quieres un botón de “publicar ya” para un gran lanzamiento? Etiqueta tus fetch y expón una pequeña ruta que invalide la etiqueta:
import { revalidateTag } from 'next/cache';
export async function POST(req: Request) {
if (req.headers.get('x-revalidate-secret') !== process.env.REVALIDATE_SECRET) {
return new Response('Nanay', { status: 401 });
}
// Next.js 16 acepta un segundo argumento; en Next.js 15 es simplemente revalidateTag('crm-content')
revalidateTag('crm-content', { expire: 0 });
return Response.json({ revalidated: true });
}Llámala desde donde le venga bien a tu equipo: un script de despliegue, un bookmarklet, un bot de chat, un botón en tu propio panel de administración. El secreto es solo tuyo: no tiene nada que ver con la clave del sitio.
Modo Live: sáltate todas las cachés
Cuando un editor abre tu web en el modo Live del CRM, quiere ver su cambio nada más guardar, no un minuto después. Dentro del marco del CRM la URL recibe ?crm_live=1. Buena práctica (y exactamente lo que hace nuestro cliente de referencia): cuando la página está dentro de un iframe o tiene crm_live, haz el fetch con cache: 'no-store'. Nuestro lado ya está fresco, así que no hace falta nada más.
const isLive =
window.self !== window.top ||
new URLSearchParams(location.search).has('crm_live');
const res = await fetch('https://back.sitecog.com/content/v1/pages/home?lang=en', {
headers: { 'x-crm-key': 'pk_3f9c2a7e1b4d4c0e8a6f5b2d9c1e7a40' },
cache: isLive ? 'no-store' : 'default',
});type Props = { searchParams: Promise<{ crm_live?: string }> };
export default async function Home({ searchParams }: Props) {
const live = (await searchParams).crm_live === '1';
const res = await fetch('https://back.sitecog.com/content/v1/pages/home?lang=en', {
headers: { 'x-crm-key': process.env.CRM_KEY! },
// los editores reciben datos frescos en cada petición; los visitantes, la copia cacheada
...(live ? { cache: 'no-store' as const } : { next: { revalidate: 60 } }),
});
const page = await res.json();
// …
}Más sobre el modo Live y el marcado que hay detrás en la página Widget y marcado.
“¿Por qué no veo mi cambio?”
La pregunta más popular sobre cualquier caché, de todos los tiempos. Recorre esta lista de arriba abajo: va de “diez segundos para comprobarlo” a “prepárate un té”.
- Pregunta directamente a la API. Un
curlnormal no tiene caché, y nuestro lado siempre está fresco. Si curl muestra el texto nuevo, la API está bien y la copia vieja vive en alguna caché por el camino. Si curl muestra el texto viejo, sigue bajando por la lista. - ¿Se guardó de verdad? Compruébalo en el CRM. Para los posts del blog: los borradores nunca se devuelven, y un post programado para el futuro sigue oculto hasta su fecha (y puede aparecer unos minutos tarde).
- ¿El sitio correcto? Una clave pertenece exactamente a un sitio. Staging y producción con claves distintas leen contenido distinto.
- ¿El idioma correcto? No hay respaldo en el servidor. Si el editor cambió el texto en alemán y tú renderizas el inglés, no pasa nada visible. Una traducción vacía vuelve como
"", y tu código de respaldo puede estar mostrando otro idioma sin decir nada. Consulta Idiomas y respaldos. - ¿El bloque correcto? Los marcadores distinguen mayúsculas y minúsculas, y los marcadores de bloque solo son únicos dentro de una sección:
/v1/blocks/titlesin?section=devuelve el bloque más antiguo con ese marcador, que puede no ser el que se editó. - ¿Sección oculta? Las secciones con
show: falsese siguen devolviendo. Si el editor ocultó una sección y sigue en la web, tu plantilla no está comprobandoshow. - Tu propia caché.
revalidate, ISR, un mapa en memoria, una CDN delante de tu web, una página generada una sola vez en el build. Es el sospechoso habitual. - El navegador. Hasta 60 segundos más un refresco en segundo plano. Una recarga forzada (Ctrl+Shift+R o Cmd+Shift+R) lo resuelve.
- ¿Editando en modo Live? Asegúrate de que dentro del marco del CRM haces el fetch con
cache: 'no-store'(mira más arriba).
curl -i "https://back.sitecog.com/content/v1/pages/home?lang=en" \
-H "x-crm-key: pk_3f9c2a7e1b4d4c0e8a6f5b2d9c1e7a40"