Tutorial

Cómo construir un bloque de artículos de blog

Construye un bloque de listado de artículos en tu propia aplicación Next.js: un loader de servidor que obtiene las entradas desde la API de entrega, más búsqueda, paginación e imágenes de portada. Recorrido completo por el código.

E
Equipo Cmssy
15 min read

Cómo construir un bloque de artículos de blog

Construye en tu propia app Next.js un bloque que obtiene y lista artículos de blog desde Cmssy a través de la API de entrega, con búsqueda y paginación.

Qué vamos a construir

Un bloque de listado de entradas de blog: una cuadrícula o lista de vistas previas, con búsqueda y paginación. Cmssy es un CMS headless con editor visual - tu contenido vive en el panel de Cmssy, y tus componentes de bloque viven en tu propia aplicación Next.js, que despliegas tú mismo. Este es exactamente el bloque que renderiza el listado desde el que has llegado a este artículo.

Aquí las entradas son páginas: cada entrada es una página hija de un padre /blog, con el tipo de página post. El bloque pide a la API de entrega los hijos de ese padre, así que añadir una entrada es simplemente publicar una página.

  • Entradas cargadas en el servidor, antes de que la página renderice
  • Imagen de portada y extracto desde los campos personalizados de la entrada
  • Búsqueda y filtrado por categoría en el cliente
  • Paginación (cargar más)
  • Modos de cuadrícula y lista

Cómo funcionan los bloques en headless

Un bloque es un componente React más un esquema de props, ambos en tu repositorio. No hay CLI ni paso de publicación. El flujo:

  1. Declara los campos editables y el componente en blocks/blog-posts/block.ts con defineBlock
  2. Escribe el componente React en blocks/blog-posts/src/
  3. Añade el bloque al array de cmssy/blocks.ts
  4. Ejecuta pnpm dev y el bloque aparece en el editor
  5. Despliega tu aplicación Next.js - así es como el bloque llega a producción

El editor enmarca tu sitio desplegado (o local) y aprende el esquema de cada bloque a través del puente del SDK, así que no hay nada que subir por separado.

Paso 1: definir el bloque

Crea blocks/blog-posts/block.ts. Fíjate en que el componente forma parte de la definición, y los campos editables van bajo props - cada uno construido con un helper fields.*, que es lo que hace que el tipo del contenido sea inferible:

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,
});

Constructores de campo 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 y fields.relation. Aquí el editor solo configura el bloque - las entradas son páginas que se obtienen en tiempo de renderizado.

Paso 2: entender las props del componente

Tipa tu componente con BlockProps<typeof yourProps> y el esquema pasa a ser el único sitio donde un campo tiene nombre: renombra un campo y el componente deja de compilar, en lugar de renderizar nada en silencio:

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 contiene los valores de los campos, data contiene lo que devolvió el loader de servidor del bloque, y context describe el entorno de renderizado:

interface CmssyBlockContext {
  locale: {
    current: string;   // p. ej. "es"
    default: string;   // idioma por defecto del workspace
    enabled: string[]; // todos los idiomas activados
  };
  isPreview: boolean;  // true dentro del editor
  page?: {             // ausente si la página no tiene slug
    id: string;
    slug: string;
    pageType: string | null;
  };
}

Usa context.locale.current para elegir los valores localizados y context.isPreview para ajustar el comportamiento dentro del editor - por ejemplo, saltarte el scroll infinito mientras alguien edita.

Paso 3: cargar las entradas en el servidor

El cliente del SDK es una pasarela GraphQL, no un conjunto de wrappers: todo lo que se pueda expresar como consulta es tu propia consulta. Ponla en blocks/blog-posts/load-posts.ts y usa queryScoped, que resuelve e inyecta el id del workspace por ti:

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;
}

Ahora cólgalo del bloque con un loader. Se ejecuta durante el SSR y su valor de retorno llega al componente como la prop data, así que la primera página de entradas está en el HTML en lugar de pedirse tras la hidratación:

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 });
  },
});

El resultado del loader cruza la frontera servidor-cliente, así que debe ser serializable para RSC: objetos simples, arrays y primitivos. El loader no se ejecuta en el editor - allí el componente recibe data: undefined, y para eso está isPreview.

Paso 4: construir el componente

Con la primera página ya en data, el componente solo acude a la red cuando el lector busca o pide más. Mantenlo en un hook para que el componente siga siendo presentacional:

"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>
  );
}

Fíjate en lo que no hay aquí: ningún título por defecto, ningún texto de relleno. Un bloque renderiza lo que le da el CMS y nada más - un valor ausente significa que el elemento no se renderiza, nunca que una cadena en inglés escrita a fuego acabe en una página en español.

Paso 5: registrar el bloque

Añádelo al array de cmssy/blocks.ts. Ese array es el que pasas a createCmssyPage, y es todo el cableado:

import { blogPostsBlock } from "@/blocks/blog-posts/block";
// ...tus otros bloques

export const blocks = [blogPostsBlock];

Sin subidas, sin comando de publicación: el editor lee cada esquema desde tu aplicación en marcha a través del puente del SDK.

Paso 6: paginación

Para cargar más, guarda un offset y llama al mismo loader mediante un route handler o una server action, añadiendo los resultados:

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);
}

Mantén las credenciales de entrega en el servidor. loadPosts solo se ejecuta del lado del servidor, así que con quien habla el navegador es con el route handler.

Ejecútalo en local

Arranca tu aplicación y el bloque aparece en el editor de inmediato:

pnpm dev

Abre tu workspace en el panel de Cmssy, suelta el bloque Blog Posts en una página y el editor enmarcará tu sitio local. Elige la página padre, pon el título y el listado se renderiza en vivo.

Llévalo a producción

No hay un paso de despliegue aparte para los bloques. Cuando despliegas tu aplicación Next.js (en Vercel o donde sea), el nuevo bloque va con ella, y el editor apunta a tu URL desplegada para la edición visual.

Configuración del contenido

Antes de que el bloque tenga algo que listar, prepara el lado del contenido en el panel de Cmssy:

  1. Crea un tipo de página post con los campos personalizados que necesita tu tarjeta - normalmente cover_image (Media), author y publish_date
  2. Crea una página padre en /blog
  3. Crea cada entrada como página hija de /blog con el tipo de página post
  4. En los ajustes del bloque, apunta Parent Page a /blog

Siguientes pasos