Server loaders
Fetch a block's data during server-side rendering so it is crawlable, renders without a loading flash, and keeps server-only dependencies out of the client bundle.
A block's loader runs on the server during SSR and passes its result to the component as the data prop. Use it to fetch content, run heavy transforms, or call the delivery API before the page reaches the browser - instead of fetching client-side in a useEffect.
Three things follow from that:
- SEO - the content is in the server-rendered HTML, so crawlers see it.
- No flash - the block renders populated on first paint. No skeleton.
- Smaller client bundle - server-only dependencies like a syntax highlighter or an HTML sanitizer never reach the browser.
The contract
import { defineBlock } from "@cmssy/react";
defineBlock({
type: "my-block",
loader: async ({ content, context }) => {
// server-side during SSR only; returns RSC-serializable data
return { html: "" };
},
component: MyBlock, // receives { content, context, data }
});Three rules govern it:
- The loader runs during SSR. It does not run in the editor - there the component receives
data: undefined. Always render a sensible fallback. - The return value crosses the server-to-client boundary, so it must be RSC-serializable: plain objects, arrays and primitives. No functions, no class instances.
contentis the block's resolved content;contextis the block context (locale,isPreview,forms).
Type data as optional and degrade when it is missing:
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 }} />;
}Keep server-only code out of the client bundle
A block module is also reachable from the editor's client bundle. If your loader statically imports a heavy or server-only dependency, that dependency gets bundled for the browser too. Two guards prevent it:
- Dynamic
import()inside the loader - the dependency is pulled in only when the loader actually runs. - A runtime
windowguard in any shared server helper - a hard failure if it is ever reached on the client.
// block.ts
loader: async ({ content }) => {
const code = typeof content.code === "string" ? content.code : "";
if (!code) return { html: "" };
const { codeToHtml } = await import("shiki"); // server-only, lazy
return { html: await codeToHtml(code, { lang: "ts", theme: "github-light" }) };
},// load-posts.ts - a server-only helper, reached via dynamic import()
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);
}The loader itself stays tiny; the helper only ever loads on the 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 });
},Calling the delivery API
Use createCmssyClient(...).queryScoped(...) or graphqlRequest from @cmssy/react.
queryScoped auto-injects the workspace id: when your query declares $workspaceId and you do not pass it, the SDK resolves it from your workspaceSlug and adds both the variable and the x-workspace-id header. You never manage the workspace id yourself.
Hybrid: SSR the first page, then go client-side
A loader does not have to own the data forever. A common pattern loads the first page on the server, seeds the client hook's initial state from data, and leaves search, pagination and filtering to the client:
function BlogPosts({ content, context, data }) {
// seed initial state from the SSR `data`; client effects handle the rest
const state = useBlogPosts(content, context, data);
// ...
}When data is present the client skips its initial fetch entirely. Only user-driven actions - typing a search, scrolling for more - hit the network.
Worked examples
Three blocks in the cmssy-web reference app use loaders, each for a different reason:
docs-code-block- server-side syntax highlighting intodata.html. Server-only dep:shiki.legal- sanitizes CMS-authored HTML intodata.sections. Server-only dep:sanitize-html.blog-posts- fetches the first page of posts intodata.items. Server-only dep: the delivery API.
When a loader throws
A loader that throws does not take the page down. The SDK catches it and contains the block:
- On your site - the block renders nothing. The rest of the page is unaffected.
- In the editor - a diagnostic card in the block's place, labelled loader failed, carrying the error message and the block id.
The asymmetry is deliberate. Visitors should never meet a stack trace, and whoever is editing should never have to guess why a block went blank.
It also means a failing loader is easy to miss in production - the page just quietly has one less section. If a block is absent from a live page and the content looks fine, open that page in the editor before suspecting the content.
Checklist
- Loader returns RSC-serializable data - plain objects, arrays, primitives.
- Component renders a fallback when
dataisundefined, so the editor still works. - Server-only dependencies sit behind a dynamic
import(). - Shared server helpers guard on
typeof window !== "undefined". - Delivery calls go through
queryScopedorgraphqlRequest, so the workspace is auto-scoped.
Next steps
- Block development -
defineBlockend to end. - Advanced block features - context, preview behaviour, edge cases.
- API & AI - what the delivery API exposes.