Tutorial

So baust du einen Blog-Posts-Block

Baue einen Blog-Listing-Block in deiner eigenen Next.js-App: ein Server-Loader, der Posts aus der Delivery-API holt, plus Suche, Pagination und Cover-Bilder. Kompletter Code-Walkthrough.

C
Cmssy Team
15 min read

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:

  1. Deklariere die editierbaren Felder und die Komponente in blocks/blog-posts/block.ts mit defineBlock
  2. Schreibe die React-Komponente in blocks/blog-posts/src/
  3. Füge den Block dem Array in cmssy/blocks.ts hinzu
  4. Starte pnpm dev und der Block erscheint im Editor
  5. 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:

  1. Lege einen Seitentyp post mit den Custom Fields an, die deine Karte braucht - typischerweise cover_image (Media), author und publish_date
  2. Lege eine Elternseite unter /blog an
  3. Erstelle jeden Post als Unterseite von /blog mit dem Seitentyp post
  4. Zeig in den Blockeinstellungen mit Parent Page auf /blog

Nächste Schritte