Ahora con creación de páginas por IA vía el servidor MCP

Loaders de servidor

Carga los datos de un bloque durante el renderizado en servidor: indexable, sin parpadeo de carga y sin dependencias de servidor en el bundle del cliente.

El loader de un bloque se ejecuta en el servidor durante el SSR y pasa su resultado al componente como la prop data. Úsalo para traer contenido, ejecutar transformaciones pesadas o llamar a la API de entrega antes de que la página llegue al navegador, en lugar de cargar en el cliente dentro de un useEffect.

De ahí se derivan tres cosas:

  • SEO: el contenido está en el HTML renderizado en servidor, así que los crawlers lo ven.
  • Sin parpadeo: el bloque se renderiza ya relleno en el primer pintado. Sin esqueletos.
  • Bundle de cliente más pequeño: las dependencias solo de servidor, como un resaltador de sintaxis o un saneador de HTML, nunca llegan al navegador.

El contrato

import { defineBlock } from "@cmssy/react";

defineBlock({
  type: "my-block",
  loader: async ({ content, context }) => {
    // solo en servidor durante el SSR; devuelve datos serializables por RSC
    return { html: "" };
  },
  component: MyBlock, // recibe { content, context, data }
});

Lo rigen tres reglas:

  1. El loader corre durante el SSR. No corre en el editor: allí el componente recibe data: undefined. Renderiza siempre un fallback sensato.
  2. El valor devuelto cruza la frontera servidor-cliente, así que debe ser serializable por RSC: objetos planos, arrays y primitivos. Sin funciones ni instancias de clase.
  3. content es el contenido resuelto del bloque; context es el contexto de bloque (locale, isPreview, forms).

Tipa data como opcional y degrada cuando falte:

function MyBlock({
  content,
  data,
}: {
  content: Record<string, unknown>;
  data?: { html?: string };
}) {
  if (!data?.html) return <pre>{String(content.code ?? "")}</pre>; // fallback del editor
  return <div dangerouslySetInnerHTML={{ __html: data.html }} />;
}

Mantén el código de servidor fuera del bundle del cliente

Un módulo de bloque también es alcanzable desde el bundle cliente del editor. Si tu loader importa estáticamente una dependencia pesada o solo de servidor, esa dependencia acaba también en el bundle del navegador. Dos salvaguardas lo evitan:

  1. import() dinámico dentro del loader: la dependencia solo se carga cuando el loader realmente se ejecuta.
  2. Una guarda de window en tiempo de ejecución en cualquier helper de servidor compartido: un fallo duro si alguna vez se alcanza en el cliente.
// block.ts
loader: async ({ content }) => {
  const code = typeof content.code === "string" ? content.code : "";
  if (!code) return { html: "" };
  const { codeToHtml } = await import("shiki"); // solo servidor, perezoso
  return { html: await codeToHtml(code, { lang: "ts", theme: "github-light" }) };
},
// load-posts.ts - helper solo de servidor, alcanzado por import() dinámico
import { createCmssyClient } from "@cmssy/react";
import { cmssy } from "@/cmssy/config";

const client = createCmssyClient(cmssy);

export async function loadPosts(vars: { parentSlug: string; limit: number }) {
  if (typeof window !== "undefined") {
    throw new Error("loadPosts must only run on the server");
  }
  return client.queryScoped(PUBLIC_PAGES_QUERY, vars);
}

El loader se mantiene diminuto; el helper solo se carga en el servidor:

loader: async ({ content }) => {
  const parentSlug = resolveParentSlug(content);
  if (!parentSlug) return null;
  const { loadPosts } = await import("./load-posts");
  return loadPosts({ parentSlug, limit: Number(content.postsPerPage) || 9 });
},

Llamar a la API de entrega

Usa createCmssyClient(...).queryScoped(...) o graphqlRequest de @cmssy/react.

queryScoped inyecta automáticamente el id del workspace: si tu consulta declara $workspaceId y no lo pasas, el SDK lo resuelve desde tu workspaceSlug y añade tanto la variable como la cabecera x-workspace-id. Nunca gestionas el id del workspace tú mismo.

Híbrido: SSR la primera página, luego cliente

Un loader no tiene que poseer los datos para siempre. Un patrón habitual carga la primera página en el servidor, siembra el estado inicial del hook cliente desde data y deja búsqueda, paginación y filtros al cliente:

function BlogPosts({ content, context, data }) {
  // estado inicial desde el `data` del SSR; los efectos de cliente hacen el resto
  const state = useBlogPosts(content, context, data);
  // ...
}

Cuando data está presente, el cliente se salta por completo su primera carga. Solo las acciones del usuario -escribir una búsqueda, desplazarse por más- tocan la red.

Ejemplos reales

Tres bloques de la app de referencia cmssy-web usan loaders, cada uno por un motivo distinto:

  • docs-code-block: resaltado de sintaxis en servidor hacia data.html. Dependencia de servidor: shiki.
  • legal: sanea el HTML escrito en el CMS hacia data.sections. Dependencia de servidor: sanitize-html.
  • blog-posts: trae la primera página de entradas hacia data.items. Dependencia de servidor: la API de entrega.

Cuando un loader lanza

Un loader que lanza no tumba la página. El SDK lo captura y contiene el bloque:

  • En tu sitio: el bloque no renderiza nada. El resto de la página queda intacto.
  • En el editor: una tarjeta de diagnóstico en el lugar del bloque, etiquetada loader failed, con el mensaje de error y el id del bloque.

La asimetría es deliberada. Un visitante no debería encontrarse nunca una traza, y quien edita no debería adivinar por qué un bloque se quedó en blanco.

También significa que un loader que falla es fácil de pasar por alto en producción: la página simplemente tiene una sección menos. Si falta un bloque en una página en vivo y el contenido parece correcto, abre esa página en el editor antes de sospechar del contenido.

Checklist

  • El loader devuelve datos serializables por RSC: objetos planos, arrays, primitivos.
  • El componente renderiza un fallback cuando data es undefined, para que el editor siga funcionando.
  • Las dependencias de servidor están tras un import() dinámico.
  • Los helpers de servidor compartidos comprueban typeof window !== "undefined".
  • Las llamadas de entrega pasan por queryScoped o graphqlRequest, con el workspace ya acotado.

Siguientes pasos