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.
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
createCmssyClientfrom@cmssy/react - Cover images from a
mediafield - 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:
- Define the block's editable fields in
blocks/blog-posts/block.tswithdefineBlock - Write the React component in
blocks/blog-posts/src/ - Register it in
cmssy/blocks.ts - Run
pnpm devand the block appears in the editor - 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 devOpen 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:
- Go to Models and create a
postmodel - Add fields:
title(Single line)excerpt(Multi line)cover_image(Media)category(Select with options)
- Create records for each post and fill in the fields
- Generate a delivery token so your block can read them
Next Steps
- Read the Block Development Guide
- See all field types and schema options
- Browse the block reference
- Follow the installation guide to set up
@cmssy/reactand@cmssy/next