Tutoriel

Comment créer un bloc d'articles de blog

Créez un bloc de liste d'articles dans votre propre application Next.js : un loader serveur qui récupère les articles depuis l'API de diffusion, plus recherche, pagination et images de couverture. Guide complet du code.

É
Équipe Cmssy
15 min read

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 :

  1. Déclarez les champs éditables et le composant dans blocks/blog-posts/block.ts avec defineBlock
  2. Écrivez le composant React dans blocks/blog-posts/src/
  3. Ajoutez le bloc au tableau dans cmssy/blocks.ts
  4. Lancez pnpm dev et le bloc apparaît dans l'éditeur
  5. 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 dev

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

  1. Créez un type de page post avec les champs personnalisés dont votre carte a besoin - typiquement cover_image (Média), author et publish_date
  2. Créez une page parente à /blog
  3. Créez chaque article comme page enfant de /blog avec le type de page post
  4. Dans les réglages du bloc, pointez Parent Page vers /blog

Prochaines étapes