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 collection block that dynamically lists CMS pages with search, pagination, and custom fields.

Last updated: March 15, 2026

What We're Building

A blog posts listing block that dynamically fetches and displays pages from your CMS. Features:

  • Server-side data injection via context.pages
  • Cover images from custom fields
  • Client-side search with debounce
  • Infinite scroll pagination
  • Category filtering
  • Grid and list layout modes

How Collection Blocks Work

Collection blocks are regular blocks that use a pageSelector field in their schema. When the platform detects this field, it automatically:

  1. Fetches child pages of the selected parent page (server-side)
  2. Injects the data into context.pages
  3. Your block renders the list from context.pages.items

This means the first page load is fully server-rendered - no loading spinners.

Step 1: Define the Schema

Create config.ts:

import { defineBlock } from "@cmssy/cli/config";

export default defineBlock({
  name: "Blog Posts",
  description: "Dynamic blog post listing with search and pagination",
  category: "content",
  tags: ["blog", "posts", "collection", "listing"],
  schema: {
    heading: {
      type: "singleLine",
      label: "Heading",
      defaultValue: "Latest Posts",
    },
    description: {
      type: "multiLine",
      label: "Description",
    },
    parentPage: {
      type: "pageSelector",
      label: "Parent Page",
      helpText: "Select the blog page whose children will be listed",
      required: true,
    },
    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,
    },
  },
});

The key field is parentPage with type pageSelector. This tells the platform to fetch child pages and inject them into context.pages.

Step 2: Access Page Data

The platform injects page data into your block's context:

interface PlatformContext {
  pages?: {
    items: PageItem[];  // Array of child pages
    total: number;      // Total count
    hasMore: boolean;   // More pages available?
  };
  language: string;
  workspace: { id: string };
  // ... other context fields
}

Each PageItem has:

interface PageItem {
  id: string;
  slug: string;
  fullSlug: string;
  publishedAt: string | null;
  displayName: Record<string, string>;  // { en: "My Post", pl: "Moj post" }
  seoDescription: Record<string, string> | null;
  customFields: Array<{ fieldKey: string; value: unknown }>;
  pageType: string;
}

Step 3: Read Custom Fields

Custom fields (like cover image, author, category) are stored in customFields array. Use a helper to extract them:

function getCustomField(item: PageItem, key: string): unknown {
  const field = item.customFields?.find((f) => f.fieldKey === key);
  return field?.value ?? null;
}

// Usage - field keys are snake_case (from page type definition)
const coverImage = getCustomField(item, "cover_image") as string | null;
const author = getCustomField(item, "author") as string | null;
const category = getCustomField(item, "category") as string | null;

Important: Field keys use snake_case because the key sanitizer in the page type editor converts all keys to lowercase with underscores.

Step 4: Build the Component

Create src/BlogPosts.tsx:

"use client";

import type { PageItem, PlatformContext } from "@cmssy/types";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { BlockContent } from "./block";

interface Props {
  content: BlockContent;
  context?: PlatformContext;
}

function getLocalizedField(
  field: Record<string, string> | null | undefined,
  language: string
): string {
  if (!field) return "";
  return field[language] ?? field.en ?? Object.values(field)[0] ?? "";
}

function getCustomField(item: PageItem, key: string): unknown {
  const field = item.customFields?.find((f) => f.fieldKey === key);
  return field?.value ?? null;
}

export default function BlogPosts({ content, context }: Props) {
  const { layout = "grid", showSearch = true, parentPage, postsPerPage } = content;
  const language = context?.language ?? "en";
  const pages = context?.pages;

  // SSR data as initial state
  const [items, setItems] = useState<PageItem[]>(pages?.items ?? []);
  const [hasMore, setHasMore] = useState(pages?.hasMore ?? false);
  const [search, setSearch] = useState("");
  const limit = Number(postsPerPage) || 9;

  // Extract parent slug from pageSelector
  const parentSlug = Array.isArray(parentPage)
    ? parentPage[0]?.slug
    : typeof parentPage === "string" ? parentPage : undefined;

  // Render post cards
  return (
    <section className="py-24">
      <div className="max-w-6xl mx-auto px-6">
        {/* Search */}
        {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"
          />
        )}

        {/* Grid */}
        <div className={`grid grid-cols-1 gap-8 ${
          layout === "grid" ? "md:grid-cols-2 lg:grid-cols-3" : ""
        }`}>
          {items.map((item) => {
            const title = getLocalizedField(item.displayName, language);
            const excerpt = getLocalizedField(item.seoDescription, language);
            const coverImage = getCustomField(item, "cover_image") as string | null;

            return (
              <a key={item.id} href={item.fullSlug}
                 className="group flex flex-col rounded-2xl overflow-hidden
                            border hover:shadow-lg transition-shadow">
                {coverImage ? (
                  <div className="aspect-video overflow-hidden">
                    <img src={coverImage} alt={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">{title}</h3>
                  {excerpt && (
                    <p className="text-sm text-muted-foreground mt-2 line-clamp-3">
                      {excerpt}
                    </p>
                  )}
                </div>
              </a>
            );
          })}
        </div>
      </div>
    </section>
  );
}

For search, fetch from the GraphQL API client-side:

const fetchPages = async (opts: { search?: string; offset?: number }) => {
  const res = await fetch('/api/graphql', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      query: `query PublicPagesByType(
        $workspaceId: String!,
        $parentSlug: String,
        $search: String,
        $limit: Int,
        $offset: Int
      ) {
        publicPagesByType(
          workspaceId: $workspaceId,
          parentSlug: $parentSlug,
          search: $search,
          limit: $limit,
          offset: $offset
        ) {
          items {
            id slug fullSlug publishedAt
            displayName seoDescription
            customFields pageType
          }
          total hasMore
        }
      }`,
      variables: {
        workspaceId: context.workspace.id,
        parentSlug,
        search: opts.search || undefined,
        limit,
        offset: opts.offset ?? 0,
      },
    }),
  });
  const json = await res.json();
  return json?.data?.publicPagesByType;
};

Step 6: Add Infinite Scroll

Use IntersectionObserver to load more pages when the user scrolls to the bottom:

const sentinelRef = useRef<HTMLDivElement>(null);

useEffect(() => {
  if (!hasMore) return;
  const sentinel = sentinelRef.current;
  if (!sentinel) return;

  const observer = new IntersectionObserver(
    (entries) => {
      if (entries[0]?.isIntersecting) {
        loadMore();
      }
    },
    { rootMargin: "200px" }
  );

  observer.observe(sentinel);
  return () => observer.disconnect();
}, [hasMore, loadMore]);

// Add sentinel at the bottom of the grid:
// <div ref={sentinelRef} />

Key Architecture Decisions

SSR First, Client Enhancement

The initial page load uses server-injected data (context.pages). No loading spinner, no layout shift. Client-side features (search, infinite scroll) enhance the experience progressively.

pageSelector Field Type

The pageSelector field is the magic that connects your block to CMS data. When the platform sees this field type, it runs the publicPagesByType query server-side during SSR and injects the results.

Custom Fields for Metadata

Blog post metadata (cover image, author, category) comes from Page Type custom fields. Define them in Settings > Page Types, and they're available in item.customFields.

Page Type Setup

Before your block can show cover images and authors, create a Page Type with custom fields:

  1. Go to Settings > Page Types
  2. Create a "Post" type with fields:
    • cover_image (Media type)
    • author (Relation to User)
    • category (Select with options)
  3. Create blog posts using this page type
  4. Fill in the custom fields on each post

Next Steps

How to Build a Blog Posts Block - Cmssy Tutorial | Cmssy