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: a grid or list of post previews, with search and pagination. 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. This is the exact block that renders the listing you are reading this post from.
Posts here are pages: each post is a child page of a /blog parent, using a post page type. The block asks the delivery API for the children of that parent, so adding a post is just publishing a page.
- Posts loaded on the server, before the page renders
- Cover image and excerpt from the post's custom fields
- Client-side search and category filtering
- Pagination (load more)
- Grid and list layout modes
How Blocks Work in a Headless Setup
A block is a React component plus a props schema, both living in your repo. There is no CLI and no publish step. The flow is:
- Declare the block's editable fields and component in
blocks/blog-posts/block.tswithdefineBlock - Write the React component in
blocks/blog-posts/src/ - Add the block to the array 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 is nothing to upload separately.
Step 1: Define the Block
Create blocks/blog-posts/block.ts. Note that the component is part of the definition, and the editable fields go under props - each one built with a fields.* helper, which is what makes the content type inferable:
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,
});Available field builders: 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 and fields.relation. Here the editor only configures the block - the posts themselves are pages fetched at render time.
Step 2: Understand the Component Props
Type your component with BlockProps<typeof yourProps> and the schema becomes the only place a field is named - rename a field and the component stops compiling instead of quietly rendering nothing:
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 holds the field values, data holds whatever the block's server loader returned, and context describes the rendering environment:
interface CmssyBlockContext {
locale: {
current: string; // e.g. "en"
default: string; // workspace default locale
enabled: string[]; // all enabled locales
};
isPreview: boolean; // true inside the editor
page?: { // absent when the page has no slug
id: string;
slug: string;
pageType: string | null;
};
}Use context.locale.current to pick localized values, and context.isPreview to tweak behaviour inside the editor - for example, skip infinite scroll while someone is editing.
Step 3: Load Posts on the Server
The SDK client is a GraphQL gateway, not a set of wrappers: anything expressible as a query is your own query. Put the query in blocks/blog-posts/load-posts.ts and use queryScoped, which resolves and injects the workspace id for you:
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;
}Now hang that off the block with a loader. It runs during SSR and its return value arrives as the component's data prop, so the first page of posts is in the HTML rather than fetched after hydration:
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 });
},
});The loader result crosses the server-to-client boundary, so it has to be RSC-serializable: plain objects, arrays and primitives. The loader does not run in the editor - there, the component receives data: undefined, which is what isPreview is for.
Step 4: Build the Component
With the first page already in data, the component only reaches for the network when the reader searches or asks for more. Keep that in a hook so the component stays presentational:
"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>
);
}Note what is not here: no default heading, no placeholder copy. A block renders what the CMS gives it and nothing else - a missing value means the element does not render, never that a hardcoded English string leaks onto a Polish page.
Step 5: Register the Block
Add it to the array in cmssy/blocks.ts. That array is what you hand to createCmssyPage, and it is the whole wiring:
import { blogPostsBlock } from "@/blocks/blog-posts/block";
// ...your other blocks
export const blocks = [blogPostsBlock];No upload, no publish command - the editor reads each schema from your running app over the SDK bridge.
Step 6: Pagination
To load more, keep an offset and call the same loader through a route handler or a server action, appending the results:
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);
}Keep the delivery credentials on the server. loadPosts runs server-side only, so the route handler is what the browser talks to.
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. Pick the parent page, set the heading, and the list renders live.
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, and the editor points at your deployed URL for visual editing.
Content Setup
Before the block has anything to list, set up the content side in the Cmssy admin:
- Create a
postpage type with the custom fields your card needs - typicallycover_image(Media),authorandpublish_date - Create a parent page at
/blog - Create each post as a child page of
/blogusing thepostpage type - In the block's settings, point Parent Page at
/blog
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