Now with AI-powered page building via MCP Server

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:

  1. The loader runs during SSR. It does not run in the editor - there the component receives data: undefined. Always render a sensible fallback.
  2. 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.
  3. content is the block's resolved content; context is 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:

  1. Dynamic import() inside the loader - the dependency is pulled in only when the loader actually runs.
  2. A runtime window guard 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 into data.html. Server-only dep: shiki.
  • legal - sanitizes CMS-authored HTML into data.sections. Server-only dep: sanitize-html.
  • blog-posts - fetches the first page of posts into data.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 data is undefined, 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 queryScoped or graphqlRequest, so the workspace is auto-scoped.

Next steps