Saltar al contenido

14 de noviembre de 2025

Integrar Neobrutalism con Payload CMS

Las librerías UI pueden acelerar notablemente tu desarrollo. En este artículo repasamos las 5 mejores para tu próximo proyecto React.

Una guía práctica, de principio a fin, para conectar Payload CMS como backend de contenido y Neobrutalism como kit de componentes de front-end, y luego despachar una lista de artículos neobrutalista y pulida en Next.js.

El resultado es excelente si tu stack se apoya en Next.js + TailwindCSS. Payload es nativo de Next y Neobrutalism está pensado con Tailwind por delante, así que ambos encajan de maravilla.


Lo que vas a construir

  • Una app de Payload CMS con una colección Posts
  • Un front-end en Next.js que obtiene los artículos desde la API REST de Payload
  • Una página /blog que renderiza una lista responsive usando componentes de Neobrutalism (Cards, Badges, Buttons)

Requisitos previos

  • Node.js 20.9+ (Payload exige Node 20.9 o una versión más reciente)
  • Next.js 15+ para el front-end (Payload es nativo de Next)
  • Cualquier base de datos compatible: SQLite (dev), Postgres o MongoDB

Consejo: para el desarrollo local, SQLite es el camino más rápido. Cambia a Postgres/Mongo al desplegar.


Parte 1 — Poner en marcha Payload CMS

Puedes añadir Payload a una app de Next.js existente o generar una nueva. Aquí vamos a generar un proyecto Payload dedicado para mantener su API limpia y portable.

1) Crear una nueva app de Payload

pnpm create payload-app
# Follow the prompts: choose a template (blank or blog), set DB (SQLite for dev), etc.
cd <your-payload-app>
pnpm dev  # or npm run dev / yarn dev
npx create-payload-app
# Follow the prompts: choose a template (blank or blog), set DB (SQLite for dev), etc.
cd <your-payload-app>
pnpm dev  # or npm run dev / yarn dev
yarn create payload-app
# Follow the prompts: choose a template (blank or blog), set DB (SQLite for dev), etc.
cd <your-payload-app>
pnpm dev  # or npm run dev / yarn dev
bunx --bun create-payload-app
# Follow the prompts: choose a template (blank or blog), set DB (SQLite for dev), etc.
cd <your-payload-app>
pnpm dev  # or npm run dev / yarn dev

Esto arranca Payload y su interfaz de administración (por lo general en http://localhost:3000/admin).

2) Definir una colección Posts

Crea src/collections/Posts.ts:

import { CollectionConfig } from 'payload/types';
 
const Posts: CollectionConfig = {
  slug: 'posts',
  admin: {
    useAsTitle: 'title',
    defaultColumns: ['title', 'publishedAt', 'status'],
  },
  access: {
    read: () => true, // public read access for blog
  },
  fields: [
    {
      name: 'title',
      type: 'text',
      required: true,
    },
    {
      name: 'slug',
      type: 'text',
      required: true,
      unique: true,
    },
    {
      name: 'excerpt',
      type: 'textarea',
    },
    {
      name: 'coverImage',
      type: 'upload',
      relationTo: 'media',
    },
    {
      name: 'status',
      type: 'select',
      options: [
        { label: 'Draft', value: 'draft' },
        { label: 'Published', value: 'published' },
      ],
      defaultValue: 'draft',
      required: true,
    },
    {
      name: 'publishedAt',
      type: 'date',
      admin: { position: 'sidebar' },
    },
    {
      name: 'content',
      type: 'richText',
    },
  ],
};
 
export default Posts;

Añádela a tu configuración principal src/payload.config.ts:

import { buildConfig } from 'payload/config';
import Posts from './collections/Posts';
 
export default buildConfig({
  serverURL: process.env.PAYLOAD_PUBLIC_SERVER_URL,
  admin: { user: 'users' },
  collections: [
    Posts,
    // Media and Users collections if you need them
  ],
});

Si todavía no tienes una colección Media o Users, ejecuta create-payload-app con una plantilla de blog o añade más adelante colecciones simples para las subidas y la autenticación.

3) Sembrar algunos artículos

Abre la interfaz de administración → PostsCreate New y añade un par de artículos publicados con fechas publishedAt pasadas.

4) Confirmar que la API REST funciona

Payload expone una API REST por defecto en /api/<collection>.

Visita:

http://localhost:3000/api/posts?limit=10&sort=-publishedAt&where[status][equals]=published

Deberías ver un JSON con tus artículos.

Al desplegar, define PAYLOAD_PUBLIC_SERVER_URL y las variables de entorno de tu base de datos. Si alojas Payload aparte de Next.js, habilita el CORS para el origen de tu front-end.


Parte 2 — Poner en marcha el front-end Next.js + Neobrutalism

Vamos a construir una app de Next.js que consume la API REST de Payload y renderiza una lista de artículos usando componentes de Neobrutalism.

1) Crear la app de Next.js

pnpm create next-app@latest retroui-payload-blog
cd retroui-payload-blog
npx create-next-app@latest retroui-payload-blog
cd retroui-payload-blog
yarn create next-app@latest retroui-payload-blog
cd retroui-payload-blog
bunx --bun create-next-app@latest retroui-payload-blog
cd retroui-payload-blog

2) Instalar TailwindCSS (si no elegiste la plantilla de Tailwind)

pnpm i -D tailwindcss postcss autoprefixer
npx tailwindcss init -p

Añade ./components/**/*.{ts,tsx} y ./app/**/*.{ts,tsx} al campo content de tailwind.config.ts. Incluye también las rutas de Neobrutalism si el instalador lo requiere.

3) Instalar Neobrutalism

Usa el instalador oficial (CLI) o la instalación manual. Ejemplo (pnpm):

pnpm add retroui

Luego importa los componentes donde los necesites. (Si Neobrutalism incluye una CLI para copiar componentes, ejecútala aquí y sigue las indicaciones).

4) Conectar con Payload (env + utilidad de fetch)

Crea .env.local:

NEXT_PUBLIC_PAYLOAD_BASE_URL=http://localhost:3000

Añade una pequeña utilidad de fetch lib/payload.ts:

export type Post = {
  id: string;
  title: string;
  slug: string;
  excerpt?: string;
  coverImage?: { url: string; filename: string } | string | null;
  status: 'draft' | 'published';
  publishedAt?: string | null;
};
 
export async function getPublishedPosts(limit = 12) {
  const base = process.env.NEXT_PUBLIC_PAYLOAD_BASE_URL;
  const url = new URL('/api/posts', base);
  url.searchParams.set('limit', String(limit));
  url.searchParams.set('sort', '-publishedAt');
  url.searchParams.set('where[status][equals]', 'published');
 
  const res = await fetch(url.toString(), { next: { revalidate: 60 } });
  if (!res.ok) throw new Error(`Failed to fetch posts: ${res.status}`);
  const data = await res.json();
  return data.docs as Post[];
}

5) Construir la página de lista de artículos con Neobrutalism

Crea app/blog/page.tsx (App Router):

import Image from 'next/image';
import Link from 'next/link';
import { getPublishedPosts, type Post } from '@/lib/payload';
 
// Example Neobrutalism components — adjust paths/names per your install
import { Card } from 'neobrutalism/card';
import { Button } from 'neobrutalism/button';
import { Badge } from 'neobrutalism/badge';
 
export const revalidate = 60; // ISR cadence
 
export default async function BlogListPage() {
  const posts = await getPublishedPosts(12);
 
  return (
    <main className="container mx-auto px-4 py-10">
      <h1 className="text-4xl font-extrabold mb-6">Blog</h1>
      <p className="text-muted-foreground mb-10">
        Latest posts from the Payload CMS backend, styled with Neobrutalism.
      </p>
 
      <section className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
        {posts.map((post) => (
          <Card key={post.id} className="p-0 overflow-hidden">
            {typeof post.coverImage === 'object' && post.coverImage?.url ? (
              <div className="relative h-44 w-full">
                <Image
                  src={post.coverImage.url}
                  alt={post.title}
                  fill
                  className="object-cover"
                />
              </div>
            ) : null}
 
            <div className="p-5 space-y-3">
              <div className="flex items-center gap-2">
                <Badge variant="outline">Article</Badge>
                {post.publishedAt ? (
                  <span className="text-xs opacity-70">
                    {new Date(post.publishedAt).toLocaleDateString()}
                  </span>
                ) : null}
              </div>
 
              <h2 className="text-2xl font-bold leading-tight">
                <Link href={`/blog/${post.slug}`}>{post.title}</Link>
              </h2>
 
              {post.excerpt ? (
                <p className="text-sm text-muted-foreground line-clamp-3">
                  {post.excerpt}
                </p>
              ) : null}
 
              <div className="pt-2">
                <Button asChild>
                  <Link href={`/blog/${post.slug}`}>Read more</Link>
                </Button>
              </div>
            </div>
          </Card>
        ))}
      </section>
    </main>
  );
}

Si tu paquete de Neobrutalism expone otros nombres o rutas de import, ajusta las líneas import { Card } from 'neobrutalism/card' en consecuencia. También puedes sustituir por cualquier otro componente de Neobrutalism (Tabs, Inputs, etc.).

6) Página de detalle de un artículo (opcional)

Crea app/blog/[slug]/page.tsx:

import Image from 'next/image';
import Link from 'next/link';
 
async function getPost(slug: string) {
  const base = process.env.NEXT_PUBLIC_PAYLOAD_BASE_URL!;
  const url = new URL('/api/posts', base);
  url.searchParams.set('limit', '1');
  url.searchParams.set('where[slug][equals]', slug);
  const res = await fetch(url.toString(), { next: { revalidate: 60 } });
  if (!res.ok) throw new Error('Failed to load post');
  const data = await res.json();
  return data.docs[0];
}
 
export default async function PostPage({ params }: { params: { slug: string } }) {
  const post = await getPost(params.slug);
  if (!post) return <div className="p-10">Not found</div>;
 
  return (
    <article className="container mx-auto px-4 py-10 max-w-3xl">
      <Link href="/blog" className="underline">← Back to blog</Link>
      <h1 className="text-4xl font-extrabold mt-3">{post.title}</h1>
      {post.coverImage?.url ? (
        <div className="relative h-80 w-full my-6">
          <Image src={post.coverImage.url} alt={post.title} fill className="object-cover" />
        </div>
      ) : null}
      {post.content?.root ? (
        // If using Payload Lexical richText renderer, render here
        <div className="prose prose-neutral dark:prose-invert">
          {/* Render your rich text */}
        </div>
      ) : (
        post.excerpt ? <p className="mt-4 text-lg">{post.excerpt}</p> : null
      )}
    </article>
  );
}

Para el rich text, usa el renderer de Payload que prefieras (por ejemplo, @payloadcms/richtext-lexical) y renderízalo en consecuencia.


Parte 3 — Notas sobre cross-origin y despliegue

Si tu app de Next.js corre en un origen distinto al de Payload:

  • Habilita el CORS en Payload: define cors: ["https://your-next-app.com", "http://localhost:3000"] en payload.config.ts.
  • Configura serverURL (y PAYLOAD_PUBLIC_SERVER_URL) correctamente para que las URL de las imágenes se resuelvan.
  • Protege los borradores: más arriba permitimos un read público. Para contenido privado, sustitúyelo por una función access.read que verifique la autenticación y los roles.

Opciones de despliegue:

  • Autoalojamiento (app de Node en un VPS) o despliegue en Vercel/Cloudflare para una DX moderna.
  • Usa SQLite para demos rápidas, Postgres/Mongo para producción.

Parte 4 — Mejoras

  • Búsqueda y filtros: usa los parámetros de consulta de Payload (por ejemplo, where[title][like]) y conecta un Input + unos Tabs de Neobrutalism para filtrar.
  • Paginación: la API REST devuelve totalDocs, limit, page; construye un "Cargar más" con un Button de Neobrutalism.
  • Imágenes: mueve coverImage a una colección Media dedicada y usa el adaptador de subidas de Payload.
  • Modo de vista previa: monta una ruta de vista previa de borradores en Next.js que obtenga los datos con las cabeceras de autenticación de borrador.
  • GraphQL: Payload también expone GraphQL; úsalo si prefieres consultas tipadas.

Resolución de problemas

  • Errores de CORS: revisa la configuración cors y asegúrate de que serverURL esté definido.
  • Imágenes que no aparecen: confirma la url devuelta para las subidas y verifica que el next.config.js de Next.js autorice el dominio de Payload bajo images.domains.
  • No se renderiza nada: inspecciona la URL de fetch en lib/payload.ts. Asegúrate de que existan registros y de que su status sea published.

Resumen

Ahora tienes:

  • Una app de Payload CMS que expone artículos vía REST
  • Un sitio de Next.js con estilo de Neobrutalism
  • Una página /blog que lista los artículos y enlaza a cada uno

A partir de aquí, añade autores, categorías y tags; dale estilo con más componentes de Neobrutalism; y ponlo en marcha.


← Volver a los artículos