Jetzt mit KI-gestütztem Page Building über den MCP-Server

Server-Loader

Lade die Daten eines Blocks während des Server-Renderings - crawlbar, ohne Ladeflackern, und ohne serverseitige Abhängigkeiten im Client-Bundle.

Der loader eines Blocks läuft während des SSR auf dem Server und reicht sein Ergebnis als data-Prop an die Komponente weiter. Nutze ihn, um Inhalte zu laden, schwere Transformationen auszuführen oder die Delivery-API aufzurufen, bevor die Seite den Browser erreicht - statt clientseitig in einem useEffect zu laden.

Daraus folgen drei Dinge:

  • SEO - der Inhalt steckt im servergerenderten HTML, Crawler sehen ihn also.
  • Kein Flackern - der Block rendert beim ersten Paint bereits gefüllt. Kein Skeleton.
  • Kleineres Client-Bundle - serverseitige Abhängigkeiten wie ein Syntax-Highlighter oder ein HTML-Sanitizer erreichen den Browser nie.

Der Vertrag

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

defineBlock({
  type: "my-block",
  loader: async ({ content, context }) => {
    // nur serverseitig während SSR; liefert RSC-serialisierbare Daten
    return { html: "" };
  },
  component: MyBlock, // erhält { content, context, data }
});

Drei Regeln gelten:

  1. Der Loader läuft während des SSR. Im Editor läuft er nicht - dort bekommt die Komponente data: undefined. Rendere immer einen sinnvollen Fallback.
  2. Der Rückgabewert überquert die Server-Client-Grenze und muss deshalb RSC-serialisierbar sein: einfache Objekte, Arrays, Primitives. Keine Funktionen, keine Klasseninstanzen.
  3. content ist der aufgelöste Inhalt des Blocks; context ist der Block-Kontext (locale, isPreview, forms).

Typisiere data als optional und degradiere, wenn es fehlt:

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

Serverseitigen Code aus dem Client-Bundle halten

Ein Block-Modul ist auch aus dem Client-Bundle des Editors erreichbar. Importiert dein Loader eine schwere oder serverseitige Abhängigkeit statisch, landet sie ebenfalls im Browser-Bundle. Zwei Schutzmaßnahmen verhindern das:

  1. Dynamisches import() im Loader - die Abhängigkeit wird erst geladen, wenn der Loader tatsächlich läuft.
  2. Ein Runtime-window-Guard in jedem gemeinsam genutzten Server-Helper - ein harter Fehler, falls er je auf dem Client erreicht wird.
// block.ts
loader: async ({ content }) => {
  const code = typeof content.code === "string" ? content.code : "";
  if (!code) return { html: "" };
  const { codeToHtml } = await import("shiki"); // serverseitig, lazy
  return { html: await codeToHtml(code, { lang: "ts", theme: "github-light" }) };
},
// load-posts.ts - reiner Server-Helper, per dynamischem import() erreicht
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);
}

Der Loader bleibt winzig; der Helper lädt ausschließlich auf dem Server:

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

Die Delivery-API aufrufen

Nutze createCmssyClient(...).queryScoped(...) oder graphqlRequest aus @cmssy/react.

queryScoped injiziert die Workspace-ID automatisch: Deklariert deine Query $workspaceId und du übergibst sie nicht, löst das SDK sie aus deinem workspaceSlug auf und ergänzt Variable und x-workspace-id-Header. Du verwaltest die Workspace-ID nie selbst.

Hybrid: erste Seite per SSR, danach clientseitig

Ein Loader muss die Daten nicht auf Dauer besitzen. Ein gängiges Muster lädt die erste Seite serverseitig, speist den Initialzustand des Client-Hooks aus data und überlässt Suche, Pagination und Filter dem Client:

function BlogPosts({ content, context, data }) {
  // Initialzustand aus dem SSR-`data`; den Rest übernehmen Client-Effects
  const state = useBlogPosts(content, context, data);
  // ...
}

Ist data vorhanden, überspringt der Client seinen ersten Fetch komplett. Nur nutzergetriebene Aktionen - Tippen einer Suche, Scrollen für mehr - gehen ans Netz.

Beispiele aus der Praxis

Drei Blöcke in der Referenz-App cmssy-web nutzen Loader, jeder aus einem anderen Grund:

  • docs-code-block - serverseitiges Syntax-Highlighting nach data.html. Serverseitige Abhängigkeit: shiki.
  • legal - sanitisiert im CMS verfassten HTML nach data.sections. Serverseitige Abhängigkeit: sanitize-html.
  • blog-posts - lädt die erste Seite Beiträge nach data.items. Serverseitige Abhängigkeit: die Delivery-API.

Wenn ein Loader wirft

Ein Loader, der wirft, reißt die Seite nicht mit. Das SDK fängt ihn ab und kapselt den Block:

  • Auf deiner Site - der Block rendert nichts. Der Rest der Seite bleibt unberührt.
  • Im Editor - an der Stelle des Blocks eine Diagnosekarte mit der Kennzeichnung loader failed, der Fehlermeldung und der Block-ID.

Die Asymmetrie ist Absicht. Besucher sollen nie einen Stacktrace sehen, und wer bearbeitet, soll nie raten müssen, warum ein Block leer blieb.

Es heißt auch: Ein fehlschlagender Loader fällt in Produktion leicht nicht auf - die Seite hat einfach still einen Abschnitt weniger. Fehlt ein Block auf einer Live-Seite und der Inhalt sieht in Ordnung aus, öffne die Seite im Editor, bevor du den Inhalt verdächtigst.

Checkliste

  • Loader liefert RSC-serialisierbare Daten - einfache Objekte, Arrays, Primitives.
  • Komponente rendert einen Fallback, wenn data undefined ist, damit der Editor weiter funktioniert.
  • Serverseitige Abhängigkeiten liegen hinter einem dynamischen import().
  • Gemeinsame Server-Helper prüfen typeof window !== "undefined".
  • Delivery-Aufrufe laufen über queryScoped oder graphqlRequest, der Workspace wird automatisch gesetzt.

Nächste Schritte