Jak stworzyc blok listy postow
Zbuduj blok we wlasnej aplikacji Next.js ktory pobiera i listuje posty z Cmssy przez delivery API, z wyszukiwaniem i paginacja.
Co budujemy
Blok listingu wpisów blogowych: siatka lub lista podglądów postów, z wyszukiwarką i paginacją. Cmssy to headless CMS z wizualnym edytorem - Twoja treść żyje w panelu Cmssy, a komponenty bloków żyją we własnej aplikacji Next.js, którą sam wdrażasz. To dokładnie ten blok, który renderuje listing, z którego trafiłeś na ten wpis.
Posty to tutaj strony: każdy post jest stroną podrzędną rodzica /blog, o typie strony post. Blok pyta delivery API o dzieci tego rodzica, więc dodanie posta to po prostu opublikowanie strony.
- Posty ładowane na serwerze, zanim strona się wyrenderuje
- Obrazek okładki i zajawka z pól własnych posta
- Wyszukiwanie i filtrowanie po kategorii po stronie klienta
- Paginacja (załaduj więcej)
- Tryby siatki i listy
Jak działają bloki w headless
Blok to komponent React plus schemat propsów, oba w Twoim repo. Nie ma CLI ani kroku publikacji. Przebieg:
- Zadeklaruj edytowalne pola i komponent w
blocks/blog-posts/block.tsprzezdefineBlock - Napisz komponent React w
blocks/blog-posts/src/ - Dodaj blok do tablicy w
cmssy/blocks.ts - Odpal
pnpm devi blok pojawia się w edytorze - Wdróż aplikację Next.js - tak trafia blok na produkcję
Edytor osadza Twoją wdrożoną (lub lokalną) stronę i uczy się schematu każdego bloku przez most SDK, więc nie ma czego osobno wgrywać.
Krok 1: Zdefiniuj blok
Utwórz blocks/blog-posts/block.ts. Zwróć uwagę, że komponent jest częścią definicji, a edytowalne pola idą pod props - każde budowane helperem fields.*, i to właśnie dzięki temu typ treści da się wywnioskować:
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,
});Dostępne buildery pól: 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 i fields.relation. Tutaj edytor tylko konfiguruje blok - same posty to strony pobierane w czasie renderowania.
Krok 2: Zrozum propsy komponentu
Otypuj komponent przez BlockProps<typeof yourProps>, a schemat stanie się jedynym miejscem, gdzie pole ma nazwę - zmień nazwę pola, a komponent przestanie się kompilować, zamiast po cichu renderować pustkę:
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 trzyma wartości pól, data trzyma to, co zwrócił serwerowy loader bloku, a context opisuje środowisko renderowania:
interface CmssyBlockContext {
locale: {
current: string; // np. "pl"
default: string; // domyślny język workspace'u
enabled: string[]; // wszystkie włączone języki
};
isPreview: boolean; // true wewnątrz edytora
page?: { // brak, gdy strona nie ma sluga
id: string;
slug: string;
pageType: string | null;
};
}Używaj context.locale.current, aby wybrać zlokalizowane wartości, i context.isPreview, aby zmienić zachowanie w edytorze - na przykład pominąć nieskończone przewijanie, gdy ktoś edytuje.
Krok 3: Załaduj posty na serwerze
Klient SDK to bramka GraphQL, a nie zestaw wrapperów: cokolwiek da się wyrazić zapytaniem, jest Twoim zapytaniem. Umieść je w blocks/blog-posts/load-posts.ts i użyj queryScoped, które samo rozwiązuje i wstrzykuje id workspace'u:
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;
}Teraz podepnij to do bloku przez loader. Uruchamia się podczas SSR, a jego wynik trafia do komponentu jako prop data, więc pierwsza strona postów jest w HTML-u, a nie dociągana po hydratacji:
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 });
},
});Wynik loadera przekracza granicę serwer-klient, więc musi być serializowalny dla RSC: zwykłe obiekty, tablice i prymitywy. Loader nie uruchamia się w edytorze - tam komponent dostaje data: undefined, i po to właśnie jest isPreview.
Krok 4: Zbuduj komponent
Skoro pierwsza strona jest już w data, komponent sięga po sieć dopiero, gdy czytelnik szuka albo prosi o więcej. Trzymaj to w hooku, żeby komponent został prezentacyjny:
"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>
);
}Zwróć uwagę, czego tu nie ma: żadnego domyślnego nagłówka, żadnych zastępczych tekstów. Blok renderuje to, co da mu CMS, i nic więcej - brak wartości oznacza, że element się nie renderuje, a nigdy że zahardkodowany angielski string wycieknie na polską stronę.
Krok 5: Zarejestruj blok
Dodaj go do tablicy w cmssy/blocks.ts. Tę tablicę podajesz do createCmssyPage i to całe podpięcie:
import { blogPostsBlock } from "@/blocks/blog-posts/block";
// ...pozostałe bloki
export const blocks = [blogPostsBlock];Bez wgrywania, bez komendy publikacji - edytor czyta każdy schemat z Twojej działającej aplikacji przez most SDK.
Krok 6: Paginacja
Aby doładować więcej, trzymaj offset i wołaj ten sam loader przez route handler lub server action, dopisując wyniki:
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);
}Trzymaj poświadczenia delivery na serwerze. loadPosts działa wyłącznie po stronie serwera, więc przeglądarka rozmawia z route handlerem.
Uruchom lokalnie
Odpal aplikację, a blok od razu pojawi się w edytorze:
pnpm devOtwórz swój workspace w panelu Cmssy, upuść blok Blog Posts na stronę, a edytor osadzi Twoją lokalną stronę. Wybierz stronę-rodzica, ustaw nagłówek i lista renderuje się na żywo.
Wdróż
Nie ma osobnego kroku wdrożenia bloku. Gdy wdrażasz aplikację Next.js (na Vercel lub gdziekolwiek), nowy blok jedzie razem z nią, a edytor celuje w Twój wdrożony URL do edycji wizualnej.
Konfiguracja treści
Zanim blok będzie miał co listować, ustaw stronę treści w panelu Cmssy:
- Utwórz typ strony
postz polami własnymi, których potrzebuje karta - zwyklecover_image(Media),authoripublish_date - Utwórz stronę-rodzica pod
/blog - Twórz każdy post jako stronę podrzędną
/blogo typiepost - W ustawieniach bloku wskaż Parent Page na
/blog
Następne kroki
- Przeczytaj przewodnik po budowaniu bloków
- Zobacz wszystkie typy pól i opcje schematu
- Przejrzyj referencję bloków
- Postępuj wg przewodnika instalacji, aby ustawić
@cmssy/reacti@cmssy/next