How to Build a Blog Posts Block
Build a collection block that dynamically lists CMS pages with search, pagination, and custom fields.
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:
- Fetches child pages of the selected parent page (server-side)
- Injects the data into
context.pages - 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>
);
}Step 5: Add Client-Side Search
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:
- Go to Settings > Page Types
- Create a "Post" type with fields:
cover_image(Media type)author(Relation to User)category(Select with options)
- Create blog posts using this page type
- Fill in the custom fields on each post
Next Steps
- Read about Collection Blocks in the documentation
- Learn about PlatformContext and all available fields
- See the Block Development Guide for more patterns