Teraz z AI - twórz strony przez serwer MCP

Loadery serwerowe

Pobieraj dane bloku podczas renderowania po stronie serwera, żeby były indeksowalne, renderowały się bez mignięcia i nie wciągały zależności serwerowych do bundla klienta.

loader bloku działa na serwerze podczas SSR i przekazuje swój wynik do komponentu jako prop data. Użyj go, żeby pobrać treść, wykonać ciężkie transformacje albo odpytać API dostawcze, zanim strona trafi do przeglądarki - zamiast pobierać po stronie klienta w useEffect.

Wynikają z tego trzy rzeczy:

  • SEO - treść jest w HTML-u wyrenderowanym na serwerze, więc crawlery ją widzą.
  • Brak mignięcia - blok renderuje się wypełniony przy pierwszym malowaniu. Żadnych szkieletów.
  • Mniejszy bundle klienta - zależności tylko serwerowe, jak highlighter składni czy sanitizer HTML, nigdy nie trafiają do przeglądarki.

Kontrakt

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

defineBlock({
  type: "my-block",
  loader: async ({ content, context }) => {
    // tylko na serwerze, podczas SSR; zwraca dane serializowalne przez RSC
    return { html: "" };
  },
  component: MyBlock, // dostaje { content, context, data }
});

Rządzą nim trzy reguły:

  1. Loader działa podczas SSR. Nie działa w edytorze - tam komponent dostaje data: undefined. Zawsze renderuj sensowny fallback.
  2. Zwracana wartość przekracza granicę serwer-klient, więc musi być serializowalna przez RSC: zwykłe obiekty, tablice i prymitywy. Żadnych funkcji, żadnych instancji klas.
  3. content to rozwiązana treść bloku; context to kontekst bloku (locale, isPreview, forms).

Typuj data jako opcjonalne i degraduj się, gdy go brak:

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

Trzymaj kod serwerowy poza bundlem klienta

Moduł bloku jest osiągalny także z klienckiego bundla edytora. Jeśli Twój loader importuje statycznie ciężką albo serwerową zależność, ta zależność ląduje też w bundlu przeglądarki. Chronią przed tym dwa zabezpieczenia:

  1. Dynamiczny import() wewnątrz loadera - zależność wciąga się dopiero, gdy loader faktycznie działa.
  2. Runtime'owy guard na window w każdym współdzielonym helperze serwerowym - twardy błąd, jeśli kiedykolwiek zostanie osiągnięty na kliencie.
// block.ts
loader: async ({ content }) => {
  const code = typeof content.code === "string" ? content.code : "";
  if (!code) return { html: "" };
  const { codeToHtml } = await import("shiki"); // serwerowe, leniwe
  return { html: await codeToHtml(code, { lang: "ts", theme: "github-light" }) };
},
// load-posts.ts - helper tylko serwerowy, sięgany przez dynamiczny 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);
}

Sam loader zostaje malutki; helper ładuje się wyłącznie na serwerze:

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

Odpytywanie API dostawczego

Użyj createCmssyClient(...).queryScoped(...) albo graphqlRequest z @cmssy/react.

queryScoped automatycznie wstrzykuje id workspace'u: gdy Twoje zapytanie deklaruje $workspaceId, a Ty go nie podasz, SDK rozwiąże je z workspaceSlug i doda zarówno zmienną, jak i nagłówek x-workspace-id. Nigdy nie zarządzasz id workspace'u sam.

Hybryda: pierwsza strona z SSR, potem klient

Loader nie musi trzymać danych na zawsze. Częsty wzorzec: załaduj pierwszą stronę na serwerze, zasil stan początkowy hooka klienckiego z data, a wyszukiwanie, paginację i filtrowanie zostaw klientowi:

function BlogPosts({ content, context, data }) {
  // stan początkowy z `data` z SSR; resztę obsługują efekty klienckie
  const state = useBlogPosts(content, context, data);
  // ...
}

Gdy data jest obecne, klient w ogóle pomija pierwszy fetch. Do sieci idą tylko akcje użytkownika - wpisanie frazy, przewinięcie po więcej.

Przykłady z życia

Trzy bloki w aplikacji referencyjnej cmssy-web używają loaderów, każdy z innego powodu:

  • docs-code-block - podkolorowanie składni na serwerze do data.html. Zależność serwerowa: shiki.
  • legal - sanityzacja HTML-a z CMS-a do data.sections. Zależność serwerowa: sanitize-html.
  • blog-posts - pobranie pierwszej strony wpisów do data.items. Zależność serwerowa: API dostawcze.

Gdy loader rzuci wyjątkiem

Loader, który rzuci wyjątkiem, nie kładzie strony. SDK go łapie i izoluje blok:

  • Na Twojej stronie - blok nie renderuje niczego. Reszta strony działa normalnie.
  • W edytorze - w miejscu bloku pojawia się karta diagnostyczna z etykietą loader failed, komunikatem błędu i id bloku.

Ta asymetria jest celowa. Odwiedzający nigdy nie powinien zobaczyć stack trace'u, a osoba edytująca nigdy nie powinna zgadywać, dlaczego blok zniknął.

Znaczy to też, że padający loader łatwo przeoczyć na produkcji - strona po prostu po cichu ma o jedną sekcję mniej. Jeśli bloku brakuje na żywej stronie, a treść wygląda poprawnie, otwórz tę stronę w edytorze, zanim zaczniesz podejrzewać treść.

Lista kontrolna

  • Loader zwraca dane serializowalne przez RSC - zwykłe obiekty, tablice, prymitywy.
  • Komponent renderuje fallback, gdy data jest undefined, żeby edytor dalej działał.
  • Zależności serwerowe siedzą za dynamicznym import().
  • Współdzielone helpery serwerowe mają guard na typeof window !== "undefined".
  • Wywołania do API idą przez queryScoped albo graphqlRequest, więc workspace jest ustawiany automatycznie.

Następne kroki