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:
- 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. - Der Rückgabewert überquert die Server-Client-Grenze und muss deshalb RSC-serialisierbar sein: einfache Objekte, Arrays, Primitives. Keine Funktionen, keine Klasseninstanzen.
contentist der aufgelöste Inhalt des Blocks;contextist 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:
- Dynamisches
import()im Loader - die Abhängigkeit wird erst geladen, wenn der Loader tatsächlich läuft. - 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 nachdata.html. Serverseitige Abhängigkeit:shiki.legal- sanitisiert im CMS verfassten HTML nachdata.sections. Serverseitige Abhängigkeit:sanitize-html.blog-posts- lädt die erste Seite Beiträge nachdata.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
dataundefinedist, 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
queryScopedodergraphqlRequest, der Workspace wird automatisch gesetzt.
Nächste Schritte
- Block-Entwicklung -
defineBlockvon A bis Z. - Fortgeschrittene Block-Features - Kontext, Preview-Verhalten, Randfälle.
- API & KI - was die Delivery-API bereitstellt.