Comment créer un bloc d'articles de blog
Construisez dans votre propre application Next.js un bloc qui récupère et liste les articles de blog depuis Cmssy via l'API de diffusion, avec recherche et pagination.
Ce que nous construisons
Un bloc de liste d'articles de blog : une grille ou une liste d'aperçus, avec recherche et pagination. Cmssy est un CMS headless avec éditeur visuel - votre contenu vit dans l'admin Cmssy, et vos composants de blocs vivent dans votre propre application Next.js que vous déployez vous-même. C'est exactement le bloc qui affiche la liste depuis laquelle vous êtes arrivé sur cet article.
Ici, les articles sont des pages : chaque article est une page enfant d'un parent /blog, avec le type de page post. Le bloc demande à l'API de diffusion les enfants de ce parent - ajouter un article revient donc simplement à publier une page.
- Articles chargés sur le serveur, avant le rendu de la page
- Image de couverture et extrait depuis les champs personnalisés de l'article
- Recherche et filtrage par catégorie côté client
- Pagination (charger plus)
- Modes d'affichage grille et liste
Comment fonctionnent les blocs en headless
Un bloc est un composant React plus un schéma de props, tous deux dans votre repo. Pas de CLI, pas d'étape de publication. Le flux :
- Déclarez les champs éditables et le composant dans
blocks/blog-posts/block.tsavecdefineBlock - Écrivez le composant React dans
blocks/blog-posts/src/ - Ajoutez le bloc au tableau dans
cmssy/blocks.ts - Lancez
pnpm devet le bloc apparaît dans l'éditeur - Déployez votre application Next.js - c'est ainsi que le bloc part en production
L'éditeur encadre votre site déployé (ou local) et apprend le schéma de chaque bloc via le pont SDK : il n'y a rien à téléverser séparément.
Étape 1 : définir le bloc
Créez blocks/blog-posts/block.ts. Notez que le composant fait partie de la définition, et que les champs éditables vont sous props - chacun construit avec un helper fields.*, ce qui rend le type du contenu inférable :
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,
});Builders de champs disponibles : 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 et fields.relation. Ici l'éditeur ne fait que configurer le bloc - les articles eux-mêmes sont des pages récupérées au moment du rendu.
Étape 2 : comprendre les props du composant
Typez votre composant avec BlockProps<typeof yourProps> et le schéma devient le seul endroit où un champ est nommé - renommez un champ et le composant cesse de compiler, au lieu de rendre silencieusement du vide :
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 contient les valeurs des champs, data contient ce qu'a retourné le loader serveur du bloc, et context décrit l'environnement de rendu :
interface CmssyBlockContext {
locale: {
current: string; // ex. "fr"
default: string; // langue par défaut du workspace
enabled: string[]; // toutes les langues activées
};
isPreview: boolean; // true à l'intérieur de l'éditeur
page?: { // absent si la page n'a pas de slug
id: string;
slug: string;
pageType: string | null;
};
}Utilisez context.locale.current pour choisir les valeurs localisées, et context.isPreview pour ajuster le comportement dans l'éditeur - par exemple désactiver le défilement infini pendant l'édition.
Étape 3 : charger les articles sur le serveur
Le client SDK est une passerelle GraphQL, pas un ensemble de wrappers : tout ce qui s'exprime en requête est votre propre requête. Mettez-la dans blocks/blog-posts/load-posts.ts et utilisez queryScoped, qui résout et injecte l'id du workspace pour vous :
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;
}Branchez ensuite cela sur le bloc avec un loader. Il s'exécute pendant le SSR et sa valeur de retour arrive au composant via la prop data : la première page d'articles est donc dans le HTML plutôt que récupérée après l'hydratation :
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 });
},
});Le résultat du loader traverse la frontière serveur-client : il doit donc être sérialisable RSC (objets simples, tableaux et primitives). Le loader ne s'exécute pas dans l'éditeur - le composant y reçoit data: undefined, et c'est précisément à ça que sert isPreview.
Étape 4 : construire le composant
Avec la première page déjà dans data, le composant ne sollicite le réseau que lorsque le lecteur cherche ou demande plus. Gardez cela dans un hook pour que le composant reste présentationnel :
"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>
);
}Remarquez ce qui n'est pas là : aucun titre par défaut, aucun texte de remplissage. Un bloc affiche ce que le CMS lui donne, et rien d'autre - une valeur manquante signifie que l'élément ne s'affiche pas, jamais qu'une chaîne anglaise en dur atterrit sur une page française.
Étape 5 : enregistrer le bloc
Ajoutez-le au tableau dans cmssy/blocks.ts. C'est ce tableau que vous passez à createCmssyPage, et c'est tout le câblage :
import { blogPostsBlock } from "@/blocks/blog-posts/block";
// ...vos autres blocs
export const blocks = [blogPostsBlock];Aucun téléversement, aucune commande de publication - l'éditeur lit chaque schéma depuis votre application en cours d'exécution via le pont SDK.
Étape 6 : la pagination
Pour charger plus, conservez un offset et appelez le même loader via un route handler ou une server action, en ajoutant les résultats :
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);
}Gardez les identifiants de diffusion sur le serveur. loadPosts ne s'exécute que côté serveur : c'est donc au route handler que le navigateur parle.
Lancer en local
Démarrez votre application et le bloc apparaît immédiatement dans l'éditeur :
pnpm devOuvrez votre workspace dans l'admin Cmssy, déposez le bloc Blog Posts sur une page, et l'éditeur encadre votre site local. Choisissez la page parente, définissez le titre, et la liste s'affiche en direct.
Mettre en production
Il n'y a pas d'étape de déploiement séparée pour les blocs. Quand vous déployez votre application Next.js (sur Vercel ou ailleurs), le nouveau bloc part avec elle, et l'éditeur pointe vers votre URL déployée pour l'édition visuelle.
Configuration du contenu
Avant que le bloc n'ait quoi que ce soit à lister, préparez le côté contenu dans l'admin Cmssy :
- Créez un type de page
postavec les champs personnalisés dont votre carte a besoin - typiquementcover_image(Média),authoretpublish_date - Créez une page parente à
/blog - Créez chaque article comme page enfant de
/blogavec le type de pagepost - Dans les réglages du bloc, pointez Parent Page vers
/blog
Prochaines étapes
- Lisez le guide de développement de blocs
- Consultez tous les types de champs et options de schéma
- Parcourez la référence des blocs
- Suivez le guide d'installation pour mettre en place
@cmssy/reactet@cmssy/next