Désormais avec création de pages par IA via le serveur MCP

Loaders serveur

Récupérez les données d'un bloc pendant le rendu serveur : indexable, sans clignotement de chargement, et sans dépendances serveur dans le bundle client.

Le loader d'un bloc s'exécute côté serveur pendant le SSR et transmet son résultat au composant via la prop data. Utilisez-le pour récupérer du contenu, exécuter des transformations lourdes ou appeler l'API de diffusion avant que la page n'atteigne le navigateur - plutôt que de charger côté client dans un useEffect.

Trois conséquences en découlent :

  • SEO - le contenu est dans le HTML rendu par le serveur, donc les crawlers le voient.
  • Pas de clignotement - le bloc s'affiche déjà rempli au premier rendu. Aucun squelette.
  • Bundle client plus léger - les dépendances purement serveur, comme un coloriseur de syntaxe ou un assainisseur HTML, n'atteignent jamais le navigateur.

Le contrat

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

defineBlock({
  type: "my-block",
  loader: async ({ content, context }) => {
    // serveur uniquement, pendant le SSR ; renvoie des données sérialisables RSC
    return { html: "" };
  },
  component: MyBlock, // reçoit { content, context, data }
});

Trois règles le régissent :

  1. Le loader s'exécute pendant le SSR. Il ne s'exécute pas dans l'éditeur - le composant y reçoit data: undefined. Prévoyez toujours un repli sensé.
  2. La valeur de retour franchit la frontière serveur-client : elle doit être sérialisable par RSC - objets simples, tableaux, primitives. Ni fonctions, ni instances de classes.
  3. content est le contenu résolu du bloc ; context est le contexte de bloc (locale, isPreview, forms).

Typez data comme optionnel et dégradez proprement en son absence :

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

Garder le code serveur hors du bundle client

Un module de bloc est aussi atteignable depuis le bundle client de l'éditeur. Si votre loader importe statiquement une dépendance lourde ou purement serveur, elle se retrouve aussi dans le bundle navigateur. Deux garde-fous l'empêchent :

  1. Un import() dynamique dans le loader - la dépendance n'est chargée que lorsque le loader s'exécute vraiment.
  2. Un garde window à l'exécution dans tout helper serveur partagé - une erreur franche s'il est un jour atteint côté client.
// block.ts
loader: async ({ content }) => {
  const code = typeof content.code === "string" ? content.code : "";
  if (!code) return { html: "" };
  const { codeToHtml } = await import("shiki"); // serveur, paresseux
  return { html: await codeToHtml(code, { lang: "ts", theme: "github-light" }) };
},
// load-posts.ts - helper serveur uniquement, atteint par import() dynamique
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);
}

Le loader reste minuscule ; le helper ne se charge que sur le serveur :

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 });
},

Appeler l'API de diffusion

Utilisez createCmssyClient(...).queryScoped(...) ou graphqlRequest depuis @cmssy/react.

queryScoped injecte automatiquement l'identifiant d'espace de travail : si votre requête déclare $workspaceId et que vous ne le passez pas, le SDK le résout depuis votre workspaceSlug et ajoute la variable ainsi que l'en-tête x-workspace-id. Vous ne gérez jamais cet identifiant vous-même.

Hybride : SSR pour la première page, puis le client

Un loader n'a pas à posséder les données pour toujours. Un motif courant charge la première page côté serveur, initialise l'état du hook client depuis data, et laisse recherche, pagination et filtres au client :

function BlogPosts({ content, context, data }) {
  // état initial issu du `data` SSR ; les effets client font le reste
  const state = useBlogPosts(content, context, data);
  // ...
}

Quand data est présent, le client saute entièrement son premier chargement. Seules les actions de l'utilisateur - saisir une recherche, faire défiler - touchent le réseau.

Exemples concrets

Trois blocs de l'application de référence cmssy-web utilisent des loaders, chacun pour une raison différente :

  • docs-code-block - coloration syntaxique côté serveur vers data.html. Dépendance serveur : shiki.
  • legal - assainit le HTML rédigé dans le CMS vers data.sections. Dépendance serveur : sanitize-html.
  • blog-posts - charge la première page d'articles vers data.items. Dépendance serveur : l'API de diffusion.

Quand un loader lève une erreur

Un loader qui lève une erreur n'emporte pas la page. Le SDK l'intercepte et confine le bloc :

  • Sur votre site - le bloc n'affiche rien. Le reste de la page n'est pas affecté.
  • Dans l'éditeur - une carte de diagnostic à la place du bloc, marquée loader failed, avec le message d'erreur et l'identifiant du bloc.

L'asymétrie est voulue. Un visiteur ne doit jamais tomber sur une trace d'appels, et la personne qui édite ne doit jamais deviner pourquoi un bloc est resté vide.

Cela veut dire aussi qu'un loader en échec passe facilement inaperçu en production : la page a simplement une section de moins. Si un bloc manque sur une page en ligne et que le contenu semble correct, ouvrez cette page dans l'éditeur avant de soupçonner le contenu.

Checklist

  • Le loader renvoie des données sérialisables RSC - objets simples, tableaux, primitives.
  • Le composant affiche un repli quand data vaut undefined, pour que l'éditeur continue de fonctionner.
  • Les dépendances serveur sont derrière un import() dynamique.
  • Les helpers serveur partagés testent typeof window !== "undefined".
  • Les appels de diffusion passent par queryScoped ou graphqlRequest, l'espace de travail est donc automatique.

Étapes suivantes