So baust du einen Blog-Posts-Block
Baue in deiner eigenen Next.js-App einen Block, der Blogposts über die Delivery-API aus Cmssy holt und auflistet - mit Suche und Pagination.
Was wir bauen
Ein Block für die Blogpost-Liste: ein Raster oder eine Liste von Post-Vorschauen, mit Suche und Pagination. Cmssy ist ein Headless CMS mit visuellem Editor - deine Inhalte leben im Cmssy-Admin, und deine Block-Komponenten leben in deiner eigenen Next.js-App, die du selbst deployst. Das ist genau der Block, der die Liste rendert, über die du zu diesem Beitrag gekommen bist.
Posts sind hier Seiten: Jeder Post ist eine Unterseite eines /blog-Elternteils mit dem Seitentyp post. Der Block fragt die Delivery-API nach den Kindern dieses Elternteils - einen Post hinzufügen heißt also einfach, eine Seite zu veröffentlichen.
- Posts werden auf dem Server geladen, bevor die Seite rendert
- Titelbild und Teaser aus den Custom Fields des Posts
- Suche und Kategoriefilter clientseitig
- Pagination (mehr laden)
- Raster- und Listen-Layout
Wie Blöcke im Headless-Setup funktionieren
Ein Block ist eine React-Komponente plus ein Props-Schema, beide in deinem Repo. Kein CLI, kein Publish-Schritt. Der Ablauf:
- Deklariere die editierbaren Felder und die Komponente in
blocks/blog-posts/block.tsmitdefineBlock - Schreibe die React-Komponente in
blocks/blog-posts/src/ - Füge den Block dem Array in
cmssy/blocks.tshinzu - Starte
pnpm devund der Block erscheint im Editor - Deploye deine Next.js-App - so geht der Block live
Der Editor bettet deine deployte (oder lokale) Site ein und lernt jedes Blockschema über die SDK-Bridge - es gibt nichts separat hochzuladen.
Schritt 1: Den Block definieren
Lege blocks/blog-posts/block.ts an. Beachte: Die Komponente ist Teil der Definition, und die editierbaren Felder stehen unter props - jedes mit einem fields.*-Helper gebaut, und genau das macht den Content-Typ ableitbar:
import { defineBlock, fields } from "@cmssy/react";
import BlogPosts from "./src/BlogPosts";
export const blogPostsProps = {
badge: fields.text({ label: "Badge", defaultValue: "Latest Posts" }),
heading: fields.text({ label: "Heading", defaultValue: "From the Blog" }),
description: fields.textarea({ label: "Description" }),
parentPage: fields.pageSelector({ label: "Parent Page", multiple: false }),
postsPerPage: fields.select({
label: "Posts per page",
defaultValue: "9",
options: ["3", "6", "9", "12"],
}),
showSearch: fields.boolean({ label: "Show Search", defaultValue: true }),
layout: fields.select({
label: "Layout",
defaultValue: "grid",
options: ["grid", "list"],
tab: "style",
}),
};
export const blogPostsBlock = defineBlock({
type: "blog-posts",
category: "Blog",
label: "Blog Posts",
description:
"Grid or list of blog post previews; for a blog index or a 'latest posts' section.",
component: BlogPosts,
props: blogPostsProps,
});Verfügbare Feld-Builder: fields.text, fields.textarea, fields.richText, fields.markdown, fields.number, fields.date, fields.datetime, fields.boolean, fields.color, fields.link, fields.url, fields.email, fields.table, fields.json, fields.form, fields.pageSelector, fields.select, fields.radio, fields.multiselect, fields.media, fields.repeater und fields.relation. Hier konfiguriert der Editor nur den Block - die Posts selbst sind Seiten, die zur Renderzeit geholt werden.
Schritt 2: Die Komponenten-Props verstehen
Typisiere deine Komponente mit BlockProps<typeof yourProps>, und das Schema wird der einzige Ort, an dem ein Feld benannt wird - benennst du ein Feld um, kompiliert die Komponente nicht mehr, statt still nichts zu rendern:
import type { BlockProps } from "@cmssy/react";
import type { blogPostsProps, BlogPostsData } from "../block";
export default function BlogPosts({
content,
context,
data,
}: BlockProps<typeof blogPostsProps, BlogPostsData | null>) {
const locale = context?.locale.current;
const isPreview = context?.isPreview ?? false;
// ...
}content hält die Feldwerte, data hält das, was der Server-Loader des Blocks zurückgab, und context beschreibt die Render-Umgebung:
interface CmssyBlockContext {
locale: {
current: string; // z. B. "de"
default: string; // Standardsprache des Workspace
enabled: string[]; // alle aktivierten Sprachen
};
isPreview: boolean; // true innerhalb des Editors
page?: { // fehlt, wenn die Seite keinen Slug hat
id: string;
slug: string;
pageType: string | null;
};
}Nutze context.locale.current, um lokalisierte Werte zu wählen, und context.isPreview, um das Verhalten im Editor anzupassen - etwa Infinite Scroll zu überspringen, während jemand bearbeitet.
Schritt 3: Posts auf dem Server laden
Der SDK-Client ist ein GraphQL-Gateway, kein Satz Wrapper: Alles, was sich als Query ausdrücken lässt, ist deine eigene Query. Leg sie in blocks/blog-posts/load-posts.ts und nutze queryScoped, das die Workspace-ID für dich auflöst und injiziert:
import { print } from "graphql";
import { createCmssyClient } from "@cmssy/react";
import type { PageItem } from "@cmssy/types";
import { cmssy } from "@/cmssy/config";
import { PublicPagesByTypeDocument } from "@/graphql/generated/graphql";
const client = createCmssyClient(cmssy);
const PUBLIC_PAGES_QUERY = print(PublicPagesByTypeDocument);
export type PostsResult = { items: PageItem[]; hasMore: boolean };
export async function loadPosts(vars: {
parentSlug: string;
limit: number;
offset?: number;
}): Promise<PostsResult | null> {
const data = await client.queryScoped<{
public?: { page?: { byType?: PostsResult | null } | null } | null;
}>(PUBLIC_PAGES_QUERY, vars);
const result = data?.public?.page?.byType;
return result
? { items: result.items ?? [], hasMore: !!result.hasMore }
: null;
}Hänge das nun per loader an den Block. Er läuft während des SSR, und sein Rückgabewert kommt als data-Prop bei der Komponente an - die erste Seite Posts steckt also im HTML statt nach der Hydration nachgeladen zu werden:
export const blogPostsBlock = defineBlock({
type: "blog-posts",
component: BlogPosts,
props: blogPostsProps,
loader: async ({ content }): Promise<BlogPostsData | null> => {
const parentPage = content.parentPage;
const parentSlug = Array.isArray(parentPage)
? (parentPage[0] as { slug?: string } | undefined)?.slug
: typeof parentPage === "string"
? parentPage
: undefined;
if (!parentSlug) return null;
const limit = Number(content.postsPerPage) || 9;
const { loadPosts } = await import("./load-posts");
return loadPosts({ parentSlug, limit, offset: 0 });
},
});Das Loader-Ergebnis überquert die Server-Client-Grenze, muss also RSC-serialisierbar sein: einfache Objekte, Arrays und Primitives. Im Editor läuft der Loader nicht - dort bekommt die Komponente data: undefined, und genau dafür ist isPreview da.
Schritt 4: Die Komponente bauen
Da die erste Seite bereits in data liegt, greift die Komponente erst zum Netzwerk, wenn jemand sucht oder mehr anfordert. Pack das in einen Hook, damit die Komponente präsentational bleibt:
"use client";
import type { BlockProps } from "@cmssy/react";
import type { blogPostsProps, BlogPostsData } from "../block";
import { PostCard } from "./PostCard";
import { useBlogPosts } from "./useBlogPosts";
export default function BlogPosts({
content,
context,
data,
}: BlockProps<typeof blogPostsProps, BlogPostsData | null>) {
const { heading, description, showSearch = true, layout = "grid" } = content;
const { filteredItems, search, setSearch, hasMore, loadMore } = useBlogPosts(
content,
context,
data,
);
return (
<section className="py-24">
<div className="max-w-6xl mx-auto px-6">
{heading && <h2 className="text-3xl font-semibold">{heading}</h2>}
{description && (
<p className="mt-4 text-muted-foreground">{description}</p>
)}
{showSearch && (
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full sm:w-80 mt-8 px-4 py-2.5 border rounded-lg"
/>
)}
<div
className={`mt-8 grid grid-cols-1 gap-8 ${
layout === "grid" ? "md:grid-cols-2 lg:grid-cols-3" : ""
}`}
>
{filteredItems.map((post) => (
<PostCard key={post.id} post={post} />
))}
</div>
{hasMore && (
<button onClick={loadMore} className="mt-10">
Load more
</button>
)}
</div>
</section>
);
}Beachte, was hier nicht steht: keine Default-Überschrift, keine Platzhaltertexte. Ein Block rendert, was das CMS ihm gibt, und sonst nichts - ein fehlender Wert heißt, dass das Element nicht rendert, nie dass ein hartkodierter englischer String auf einer deutschen Seite landet.
Schritt 5: Den Block registrieren
Füg ihn dem Array in cmssy/blocks.ts hinzu. Dieses Array übergibst du an createCmssyPage - das ist die gesamte Verdrahtung:
import { blogPostsBlock } from "@/blocks/blog-posts/block";
// ...deine anderen Blöcke
export const blocks = [blogPostsBlock];Kein Upload, kein Publish-Befehl - der Editor liest jedes Schema über die SDK-Bridge aus deiner laufenden App.
Schritt 6: Pagination
Zum Nachladen hältst du einen Offset und rufst denselben Loader über einen Route Handler oder eine Server Action auf, wobei du die Ergebnisse anhängst:
const [offset, setOffset] = useState(0);
async function loadMore() {
const next = offset + limit;
const res = await fetch(
`/api/posts?parent=${parentSlug}&limit=${limit}&offset=${next}`,
).then((r) => r.json());
setItems((prev) => [...prev, ...res.items]);
setHasMore(res.hasMore);
setOffset(next);
}Halte die Delivery-Credentials auf dem Server. loadPosts läuft ausschließlich serverseitig, der Browser spricht also mit dem Route Handler.
Lokal ausführen
Starte deine App, und der Block taucht sofort im Editor auf:
pnpm devÖffne deinen Workspace im Cmssy-Admin, zieh den Blog-Posts-Block auf eine Seite, und der Editor bettet deine lokale Site ein. Wähl die Elternseite, setz die Überschrift, und die Liste rendert live.
Live gehen
Es gibt keinen separaten Block-Deploy-Schritt. Wenn du deine Next.js-App deployst (auf Vercel oder anderswo), geht der neue Block mit, und der Editor zeigt zum visuellen Bearbeiten auf deine deployte URL.
Content-Setup
Bevor der Block etwas zu listen hat, richte die Inhaltsseite im Cmssy-Admin ein:
- Lege einen Seitentyp
postmit den Custom Fields an, die deine Karte braucht - typischerweisecover_image(Media),authorundpublish_date - Lege eine Elternseite unter
/blogan - Erstelle jeden Post als Unterseite von
/blogmit dem Seitentyppost - Zeig in den Blockeinstellungen mit Parent Page auf
/blog
Nächste Schritte
- Lies den Guide zur Block-Entwicklung
- Sieh dir alle Feldtypen und Schema-Optionen an
- Stöbere in der Block-Referenz
- Folge dem Installations-Guide, um
@cmssy/reactund@cmssy/nexteinzurichten