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:
- Declara los campos editables y el componente en
blocks/blog-posts/block.tscondefineBlock - Escribe el componente React en
blocks/blog-posts/src/ - Añade el bloque al array de
cmssy/blocks.ts - Ejecuta
pnpm devy el bloque aparece en el editor - 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 devAbre 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:
- Crea un tipo de página
postcon los campos personalizados que necesita tu tarjeta - normalmentecover_image(Media),authorypublish_date - Crea una página padre en
/blog - Crea cada entrada como página hija de
/blogcon el tipo de páginapost - En los ajustes del bloque, apunta Parent Page a
/blog
Siguientes pasos
- Lee la guía de desarrollo de bloques
- Consulta todos los tipos de campo y opciones de esquema
- Explora la referencia de bloques
- Sigue la guía de instalación para configurar
@cmssy/reacty@cmssy/next