Jetzt mit KI-gestütztem Page Building über den MCP-Server
Tutorial

So baust du einen Blog-Posts-Block

Erstelle einen dynamischen Blog-Listing-Block mit Collection-Daten, Suche, Infinite Scroll und Cover-Bildern. 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.

Last updated: 15. März 2026

Was wir bauen

Ein Blog-Listing-Block, der Posts aus deinem Cmssy-Workspace über die Delivery-API holt und anzeigt. Cmssy ist ein Headless CMS mit visuellem Editor: Deine Inhalte leben im Cmssy-Admin, deine Block-Komponenten in deiner eigenen Next.js-App, die du selbst deployst. Features:

  • Posts geladen mit createCmssyClient aus @cmssy/react
  • Cover-Bilder aus einem media-Feld
  • Clientseitige Suche
  • Pagination (mehr laden)
  • Kategorie-Filter
  • Grid- und Listen-Layout

Wie Blöcke in einem Headless-Setup funktionieren

Ein Block ist einfach eine React-Komponente plus ein Schema, beide in deinem Repo. Du führst kein CLI und keinen Publish-Schritt aus. Der Ablauf:

  1. Definiere die editierbaren Felder des Blocks in blocks/blog-posts/block.ts mit defineBlock
  2. Schreibe die React-Komponente in blocks/blog-posts/src/
  3. Registriere sie in cmssy/blocks.ts
  4. Starte pnpm dev und der Block erscheint im Editor
  5. Deploye deine Next.js-App - so wird der Block ausgeliefert

Der Editor rahmt deine deployte (oder lokale Dev-)Site ein und lernt das Schema jedes Blocks über die SDK-Bridge - es gibt also nichts separat hochzuladen.

Schritt 1: Definiere den Block

Erstelle blocks/blog-posts/block.ts:

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

export const blogPostsBlock = defineBlock({
  name: "Blog Posts",
  description: "Dynamic blog post listing with search and pagination",
  category: "content",
  fields: {
    heading: {
      type: "singleLine",
      label: "Heading",
      defaultValue: "Latest Posts",
    },
    description: {
      type: "multiLine",
      label: "Description",
    },
    postModel: {
      type: "singleLine",
      label: "Post model",
      helpText: "API key of the model holding your posts (e.g. \"post\")",
      defaultValue: "post",
    },
    postsPerPage: {
      type: "numeric",
      label: "Posts per page",
      defaultValue: 9,
    },
    layout: {
      type: "select",
      label: "Layout",
      options: [
        { label: "Grid", value: "grid" },
        { label: "List", value: "list" },
      ],
      defaultValue: "grid",
    },
    showSearch: {
      type: "boolean",
      label: "Show Search",
      defaultValue: true,
    },
  },
});

Verfügbare Feldtypen: singleLine, multiLine, richText, numeric, date, media, link, select, multiselect, boolean, color und repeater. Hier konfiguriert der Editor nur den Block selbst - die Posts kommen aus einem Content-Model, das du zur Renderzeit abrufst.

Schritt 2: Verstehe die Komponenten-Props

Deine Komponente erhält { content, context }. content hält die Feldwerte aus defineBlock; context beschreibt die Rendering-Umgebung:

interface BlockContext {
  locale: {
    current: string;   // e.g. "en"
    default: string;   // workspace default locale
    enabled: string[]; // all enabled locales
  };
  isPreview: boolean;  // true inside the editor
}

Nutze context.locale.current, um die richtigen lokalisierten Werte zu wählen, und context.isPreview, um das Verhalten im Editor anzupassen (zum Beispiel Infinite Scroll beim Bearbeiten überspringen).

Schritt 3: Lade Posts über die Delivery-API

Posts leben als Records eines Content-Models (hier ein post-Model) in deinem Workspace. Lade sie mit createCmssyClient aus @cmssy/react, das über GraphQL mit der Delivery-API spricht:

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

const cmssy = createCmssyClient({
  workspace: process.env.NEXT_PUBLIC_CMSSY_WORKSPACE!,
  token: process.env.NEXT_PUBLIC_CMSSY_DELIVERY_TOKEN!,
});

async function fetchPosts(opts: {
  model: string;
  locale: string;
  search?: string;
  limit: number;
  offset?: number;
}) {
  const { items, total, hasMore } = await cmssy.records.list({
    model: opts.model,
    locale: opts.locale,
    search: opts.search,
    limit: opts.limit,
    offset: opts.offset ?? 0,
  });
  return { items, total, hasMore };
}

Jeder Record stellt die Felder bereit, die du auf dem Model definiert hast. Bei einem Blogpost sind das typischerweise Titel, Excerpt, Slug und ein cover_image-Media-Feld. Lies sie direkt vom Record, den die Delivery-API zurückgibt.

Schritt 4: Baue die Komponente

Erstelle blocks/blog-posts/src/blog-posts.tsx:

"use client";

import { createCmssyClient } from "@cmssy/react";
import { useEffect, useState } from "react";
import type { BlogPostsContent } from "../block";

interface BlockContext {
  locale: { current: string; default: string; enabled: string[] };
  isPreview: boolean;
}

interface Props {
  content: BlogPostsContent;
  context: BlockContext;
}

interface Post {
  id: string;
  slug: string;
  title: string;
  excerpt?: string;
  cover_image?: string | null;
}

const cmssy = createCmssyClient({
  workspace: process.env.NEXT_PUBLIC_CMSSY_WORKSPACE!,
  token: process.env.NEXT_PUBLIC_CMSSY_DELIVERY_TOKEN!,
});

export function BlogPosts({ content, context }: Props) {
  const { layout = "grid", showSearch = true, postModel = "post", postsPerPage } = content;
  const locale = context.locale.current;
  const limit = Number(postsPerPage) || 9;

  const [items, setItems] = useState<Post[]>([]);
  const [hasMore, setHasMore] = useState(false);
  const [search, setSearch] = useState("");

  useEffect(() => {
    let active = true;
    cmssy.records
      .list({ model: postModel, locale, search: search || undefined, limit })
      .then((res) => {
        if (!active) return;
        setItems(res.items as Post[]);
        setHasMore(res.hasMore);
      });
    return () => {
      active = false;
    };
  }, [postModel, locale, search, limit]);

  return (
    <section className="py-24">
      <div className="max-w-6xl mx-auto px-6">
        {showSearch && (
          <input
            type="text"
            value={search}
            onChange={(e) => setSearch(e.target.value)}
            placeholder="Search posts..."
            className="w-full sm:w-80 mb-8 px-4 py-2.5 border rounded-lg"
          />
        )}

        <div className={`grid grid-cols-1 gap-8 ${
          layout === "grid" ? "md:grid-cols-2 lg:grid-cols-3" : ""
        }`}>
          {items.map((post) => (
            <a key={post.id} href={`/blog/${post.slug}`}
               className="group flex flex-col rounded-2xl overflow-hidden
                          border hover:shadow-lg transition-shadow">
              {post.cover_image ? (
                <div className="aspect-video overflow-hidden">
                  <img src={post.cover_image} alt={post.title} loading="lazy"
                       className="w-full h-full object-cover
                                  group-hover:scale-105 transition-transform" />
                </div>
              ) : (
                <div className="aspect-video bg-muted" />
              )}
              <div className="p-5">
                <h3 className="text-lg font-semibold">{post.title}</h3>
                {post.excerpt && (
                  <p className="text-sm text-muted-foreground mt-2 line-clamp-3">
                    {post.excerpt}
                  </p>
                )}
              </div>
            </a>
          ))}
        </div>
      </div>
    </section>
  );
}

Schritt 5: Registriere den Block

Füge den Block zu cmssy/blocks.ts hinzu, damit Editor und Renderer ihn kennen:

import { blogPostsBlock } from "../blocks/blog-posts/block";
import { BlogPosts } from "../blocks/blog-posts/src/blog-posts";

export const blocks = {
  "blog-posts": {
    ...blogPostsBlock,
    component: BlogPosts,
  },
  // ...your other blocks
};

Das ist die ganze Verkabelung. Kein Upload, kein Publish-Befehl - der Editor liest das Schema über die SDK-Bridge aus deiner App.

Schritt 6: Füge Pagination hinzu

Um mehr Posts zu laden, halte einen offset und hänge Ergebnisse an, wenn der Nutzer auf "Load more" klickt (oder wenn ein Sentinel in den Viewport scrollt):

const [offset, setOffset] = useState(0);

async function loadMore() {
  const next = offset + limit;
  const res = await cmssy.records.list({
    model: postModel,
    locale,
    search: search || undefined,
    limit,
    offset: next,
  });
  setItems((prev) => [...prev, ...(res.items as Post[])]);
  setHasMore(res.hasMore);
  setOffset(next);
}

// Render under the grid:
// {hasMore && <button onClick={loadMore}>Load more</button>}

Im Editor kannst du das mit context.isPreview abkürzen, sodass Redakteure eine repräsentative erste Seite sehen, ohne zusätzliche Fetches auszulösen.

Lokal ausführen

Starte deine App und der Block erscheint sofort im Editor:

pnpm dev

Öffne deinen Workspace im Cmssy-Admin, ziehe den Blog-Posts-Block auf eine Seite, und der Editor rahmt deine lokale Site ein. Konfiguriere die Überschrift, wähle das Post-Model, und die Liste rendert live aus der Delivery-API.

Ausliefern

Es gibt keinen separaten Block-Deploy-Schritt. Wenn du deine Next.js-App deployst (zu Vercel oder anderswo), wird der neue Block mitgeliefert. Der Editor zeigt für das visuelle Editing auf deine deployte URL. Setze NEXT_PUBLIC_CMSSY_WORKSPACE und einen Delivery-Token in deiner Produktionsumgebung - fertig.

Model-Setup

Bevor dein Block etwas zum Auflisten hat, erstelle das Content-Model im Cmssy-Admin:

  1. Gehe zu Models und erstelle ein post-Model
  2. Füge Felder hinzu:
    • title (Single line)
    • excerpt (Multi line)
    • cover_image (Media)
    • category (Select mit Optionen)
  3. Erstelle Records für jeden Post und fülle die Felder aus
  4. Generiere einen Delivery-Token, damit dein Block sie lesen kann

Nächste Schritte