Now with AI-powered page building via MCP Server
Tutorial

How to Build a Blog Posts Block

Create a dynamic blog listing block with collection data, search, infinite scroll, and cover images. Complete code walkthrough.

C
Cmssy Team
15 min read

How to Build a Blog Posts Block

Build a block in your own Next.js app that fetches and lists blog posts from Cmssy through the delivery API, with search and pagination.

Last updated: March 15, 2026

What We're Building

A blog posts listing block that fetches and displays posts from your Cmssy workspace through the delivery API. Cmssy is a headless CMS with a visual editor: your content lives in the Cmssy admin, and your block components live in your own Next.js app that you deploy yourself. Features:

  • Posts fetched with createCmssyClient from @cmssy/react
  • Cover images from a media field
  • Client-side search
  • Pagination (load more)
  • Category filtering
  • Grid and list layout modes

How Blocks Work in a Headless Setup

A block is just a React component plus a schema, both living in your repo. You don't run a CLI or a publish step. The flow is:

  1. Define the block's editable fields in blocks/blog-posts/block.ts with defineBlock
  2. Write the React component in blocks/blog-posts/src/
  3. Register it in cmssy/blocks.ts
  4. Run pnpm dev and the block appears in the editor
  5. Deploy your Next.js app - that's how the block ships

The editor frames your deployed (or local dev) site and learns each block's schema over the SDK bridge, so there's nothing to upload separately.

Step 1: Define the Block

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

Available field types: singleLine, multiLine, richText, numeric, date, media, link, select, multiselect, boolean, color, and repeater. Here the editor only configures the block - the posts themselves come from a content model you fetch at render time.

Step 2: Understand the Component Props

Your component receives { content, context }. content holds the field values from defineBlock; context describes the rendering environment:

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

Use context.locale.current to pick the right localized values, and context.isPreview to tweak behavior inside the editor (for example, skip infinite scroll while editing).

Step 3: Fetch Posts with the Delivery API

Posts live as records of a content model (here, a post model) in your workspace. Fetch them with createCmssyClient from @cmssy/react, which talks to the delivery API over GraphQL:

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

Each record exposes the fields you defined on the model. For a blog post that's typically a title, an excerpt, a slug, and a cover_image media field. Read them straight off the record returned by the delivery API.

Step 4: Build the Component

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

Step 5: Register the Block

Add the block to cmssy/blocks.ts so the editor and your renderer both know about it:

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

That's the whole wiring. No upload, no publish command - the editor reads the schema from your app over the SDK bridge.

Step 6: Add Pagination

To load more posts, keep an offset and append results when the user clicks "Load more" (or when a sentinel scrolls into view):

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>}

Inside the editor you can short-circuit this with context.isPreview so editors see a representative first page without triggering extra fetches.

Run It Locally

Start your app and the block shows up in the editor straight away:

pnpm dev

Open your workspace in the Cmssy admin, drop the Blog Posts block onto a page, and the editor frames your local site. Configure the heading, pick the post model, and the list renders live from the delivery API.

Ship It

There is no separate block deploy step. When you deploy your Next.js app (to Vercel or anywhere else), the new block ships with it. The editor points at your deployed URL for visual editing. Set NEXT_PUBLIC_CMSSY_WORKSPACE and a delivery token in your production environment and you're done.

Model Setup

Before your block has anything to list, create the content model in the Cmssy admin:

  1. Go to Models and create a post model
  2. Add fields:
    • title (Single line)
    • excerpt (Multi line)
    • cover_image (Media)
    • category (Select with options)
  3. Create records for each post and fill in the fields
  4. Generate a delivery token so your block can read them

Next Steps

How to Build a Blog Posts Block - Cmssy Tutorial