Tutorial

How to Build a Blog Posts Block

Build a blog listing block in your own Next.js app: a server loader that fetches posts from the delivery API, plus search, pagination 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.

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:

  1. Declare the block's editable fields and component in blocks/blog-posts/block.ts with defineBlock
  2. Write the React component in blocks/blog-posts/src/
  3. Add the block to the array 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 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 dev

Open 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:

  1. Create a post page type with the custom fields your card needs - typically cover_image (Media), author and publish_date
  2. Create a parent page at /blog
  3. Create each post as a child page of /blog using the post page type
  4. In the block's settings, point Parent Page at /blog

Next Steps