Instalación y Configuración de Payload CMS

Payload CMS es un headless CMS de código abierto construido con TypeScript y Next.js que ofrece una experiencia de desarrollo de primera clase: tipo seguro de extremo a extremo, configuración como código, API REST y GraphQL generada automáticamente, y un panel de administración personalizable. A diferencia de otros CMS, Payload se configura enteramente en código TypeScript, lo que facilita el versionado y la reproducibilidad. Esta guía cubre la instalación, configuración de colecciones y despliegue en Linux.

Requisitos Previos

  • Node.js 18+ instalado
  • MongoDB o PostgreSQL
  • 1 GB RAM mínimo
  • npm o yarn
# Instalar Node.js 20 (LTS)
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo bash -
sudo apt-get install -y nodejs

# Verificar versiones
node --version
npm --version

# Instalar MongoDB (opción más simple para comenzar)
curl -fsSL https://www.mongodb.org/static/pgp/server-7.0.asc | \
    sudo gpg -o /usr/share/keyrings/mongodb-server-7.0.gpg --dearmor
echo "deb [ arch=amd64,arm64 signed-by=/usr/share/keyrings/mongodb-server-7.0.gpg ] https://repo.mongodb.org/apt/ubuntu jammy/mongodb-org/7.0 multiverse" | \
    sudo tee /etc/apt/sources.list.d/mongodb-org-7.0.list
sudo apt-get update && sudo apt-get install -y mongodb-org
sudo systemctl enable --now mongod

Instalación del Proyecto

# Crear un nuevo proyecto Payload CMS
npx create-payload-app@latest mi-cms

# El asistente preguntará:
# - Nombre del proyecto: mi-cms
# - Plantilla: blank (en blanco) o website, ecommerce, etc.
# - Base de datos: MongoDB o PostgreSQL
# - URL de la base de datos

# Entrar al directorio del proyecto
cd mi-cms

# Instalar dependencias (si no se hizo automáticamente)
npm install

# Iniciar en modo desarrollo
npm run dev
# Panel disponible en http://localhost:3000/admin

Estructura del proyecto

mi-cms/
├── src/
│   ├── app/                    # Rutas de Next.js App Router
│   │   ├── (frontend)/         # Páginas públicas de tu sitio
│   │   └── (payload)/          # Panel de administración (auto)
│   ├── collections/            # Definición de colecciones
│   │   ├── Users.ts
│   │   ├── Media.ts
│   │   └── Posts.ts
│   ├── globals/                # Datos globales del sitio
│   │   └── SiteSettings.ts
│   └── payload.config.ts       # Configuración principal
├── .env                        # Variables de entorno
└── package.json

Variables de entorno

# .env
PAYLOAD_SECRET=secreto-muy-seguro-de-al-menos-32-caracteres
DATABASE_URI=mongodb://localhost:27017/mi-cms

# Para PostgreSQL:
# DATABASE_URI=postgresql://usuario:password@localhost:5432/mi-cms

# URL pública (para generación de URLs absolutas)
NEXT_PUBLIC_SERVER_URL=https://cms.tudominio.com

Configuración de Colecciones

Las colecciones se definen como objetos TypeScript en src/collections/:

// src/collections/Posts.ts
import { CollectionConfig } from 'payload'

const Posts: CollectionConfig = {
    slug: 'posts',
    labels: {
        singular: 'Artículo',
        plural: 'Artículos',
    },
    admin: {
        useAsTitle: 'titulo',
        defaultColumns: ['titulo', 'estado', 'autor', 'fechaPublicacion'],
        group: 'Contenido',
    },
    access: {
        // Solo admins y editores pueden crear
        create: ({ req: { user } }) => {
            return Boolean(user && ['admin', 'editor'].includes(user.rol))
        },
        // Artículos publicados son públicos
        read: ({ req: { user } }) => {
            if (user) return true // Usuarios autenticados ven todo
            return { estado: { equals: 'publicado' } } // Públicos solo ven publicados
        },
        update: ({ req: { user } }) => {
            if (!user) return false
            if (user.rol === 'admin') return true
            return { autor: { equals: user.id } } // Editores solo sus artículos
        },
        delete: ({ req: { user } }) => user?.rol === 'admin',
    },
    fields: [
        {
            name: 'titulo',
            type: 'text',
            required: true,
            label: 'Título',
        },
        {
            name: 'slug',
            type: 'text',
            required: true,
            unique: true,
            admin: { position: 'sidebar' },
        },
        {
            name: 'contenido',
            type: 'richText',
            label: 'Contenido',
        },
        {
            name: 'imagen',
            type: 'upload',
            relationTo: 'media',
            label: 'Imagen destacada',
        },
        {
            name: 'autor',
            type: 'relationship',
            relationTo: 'users',
            required: true,
            label: 'Autor',
        },
        {
            name: 'categorias',
            type: 'relationship',
            relationTo: 'categorias',
            hasMany: true,
            label: 'Categorías',
        },
        {
            name: 'estado',
            type: 'select',
            options: [
                { label: 'Borrador', value: 'borrador' },
                { label: 'Revisión', value: 'revision' },
                { label: 'Publicado', value: 'publicado' },
            ],
            defaultValue: 'borrador',
            required: true,
            admin: { position: 'sidebar' },
        },
        {
            name: 'fechaPublicacion',
            type: 'date',
            label: 'Fecha de publicación',
            admin: {
                position: 'sidebar',
                date: { pickerAppearance: 'dayAndTime' },
            },
        },
        // Campos SEO
        {
            name: 'seo',
            type: 'group',
            label: 'SEO',
            fields: [
                { name: 'titulo', type: 'text', label: 'Título SEO' },
                { name: 'descripcion', type: 'textarea', label: 'Descripción SEO' },
                { name: 'imagen', type: 'upload', relationTo: 'media', label: 'Imagen OG' },
            ],
        },
    ],
    versions: {
        drafts: true,          // Habilitar borradores
        maxPerDoc: 10,         // Máximo 10 versiones por documento
    },
}

export default Posts

Registrar la colección en la configuración principal

// src/payload.config.ts
import { buildConfig } from 'payload'
import { mongooseAdapter } from '@payloadcms/db-mongodb'
import { lexicalEditor } from '@payloadcms/richtext-lexical'
import Posts from './collections/Posts'
import Users from './collections/Users'
import Media from './collections/Media'
import Categorias from './collections/Categorias'

export default buildConfig({
    serverURL: process.env.NEXT_PUBLIC_SERVER_URL || 'http://localhost:3000',
    admin: {
        user: Users.slug,
        meta: {
            titleSuffix: '— Mi CMS',
        },
    },
    collections: [
        Users,
        Posts,
        Media,
        Categorias,
    ],
    editor: lexicalEditor({}),
    secret: process.env.PAYLOAD_SECRET || '',
    db: mongooseAdapter({
        url: process.env.DATABASE_URI || '',
    }),
    typescript: {
        outputFile: 'src/payload-types.ts',
    },
    graphQL: {
        schemaOutputFile: 'src/generated-schema.graphql',
    },
})

Control de Acceso

// src/collections/Users.ts - Colección de usuarios con roles
import { CollectionConfig } from 'payload'

const Users: CollectionConfig = {
    slug: 'users',
    auth: true, // Habilita autenticación
    admin: {
        useAsTitle: 'email',
    },
    access: {
        create: ({ req: { user } }) => user?.rol === 'admin',
        read: ({ req: { user } }) => {
            if (user?.rol === 'admin') return true
            return { id: { equals: user?.id } } // Solo su propio perfil
        },
        update: ({ req: { user } }) => {
            if (user?.rol === 'admin') return true
            return { id: { equals: user?.id } }
        },
        delete: ({ req: { user } }) => user?.rol === 'admin',
    },
    fields: [
        {
            name: 'nombre',
            type: 'text',
            required: true,
        },
        {
            name: 'rol',
            type: 'select',
            options: ['admin', 'editor', 'autor'],
            defaultValue: 'autor',
            required: true,
            // Solo admins pueden cambiar el rol
            access: {
                update: ({ req: { user } }) => user?.rol === 'admin',
            },
        },
    ],
}

export default Users

Hooks y Lógica de Negocio

Los hooks permiten ejecutar lógica antes y después de operaciones CRUD:

// src/collections/Posts.ts - Con hooks completos
import { CollectionConfig } from 'payload'
import slugify from 'slugify'

const Posts: CollectionConfig = {
    slug: 'posts',
    hooks: {
        // Ejecutar antes de crear o actualizar
        beforeChange: [
            ({ data, operation }) => {
                // Generar slug automáticamente si no existe
                if (operation === 'create' && !data.slug) {
                    data.slug = slugify(data.titulo, {
                        lower: true,
                        strict: true,
                        locale: 'es',
                    })
                }
                
                // Calcular tiempo de lectura
                if (data.contenido) {
                    const texto = JSON.stringify(data.contenido)
                    const palabras = texto.split(' ').length
                    data.tiempoLectura = Math.ceil(palabras / 200)
                }
                
                return data
            },
        ],
        
        // Ejecutar después de crear
        afterChange: [
            async ({ doc, operation, req }) => {
                // Notificar a servicio externo al publicar
                if (operation === 'update' && doc.estado === 'publicado') {
                    try {
                        await fetch('https://api.servicio-externo.com/indexar', {
                            method: 'POST',
                            headers: { 'Content-Type': 'application/json' },
                            body: JSON.stringify({
                                url: `https://tudominio.com/blog/${doc.slug}`,
                                titulo: doc.titulo,
                            }),
                        })
                        req.payload.logger.info(`Artículo indexado: ${doc.slug}`)
                    } catch (error) {
                        req.payload.logger.error(`Error al indexar: ${error}`)
                    }
                }
            },
        ],
        
        // Ejecutar antes de leer (para queries)
        beforeRead: [
            ({ query }) => {
                // Modificar query antes de ejecutar
                return query
            },
        ],
    },
    fields: [/* ... */],
}

API REST y GraphQL

Payload genera automáticamente endpoints para todas las colecciones:

# API REST automática:
# GET    /api/posts              - Listar artículos
# POST   /api/posts              - Crear artículo
# GET    /api/posts/:id          - Obtener artículo
# PATCH  /api/posts/:id          - Actualizar artículo
# DELETE /api/posts/:id          - Eliminar artículo

# Con filtros y paginación
curl "http://localhost:3000/api/posts?where[estado][equals]=publicado&limit=10&sort=-fechaPublicacion" \
    -H "Authorization: Bearer TU_TOKEN"

# Relaciones pobladas
curl "http://localhost:3000/api/posts?depth=2" \
    -H "Authorization: Bearer TU_TOKEN"

# Autenticarse y obtener token
curl -X POST http://localhost:3000/api/users/login \
    -H "Content-Type: application/json" \
    -d '{"email":"[email protected]","password":"Password123!"}'
# GraphQL disponible en /api/graphql
query ObtenerArticulos($estado: String!) {
    Posts(where: { estado: { equals: $estado } }, sort: "-fechaPublicacion", limit: 10) {
        docs {
            id
            titulo
            slug
            contenido
            fechaPublicacion
            autor {
                nombre
                email
            }
            categorias {
                nombre
                slug
            }
        }
        totalDocs
        totalPages
    }
}

Integración con Next.js

Payload está diseñado para integrarse perfectamente con Next.js App Router:

// src/app/(frontend)/blog/[slug]/page.tsx
import { getPayloadHMR } from '@payloadcms/next/utilities'
import configPromise from '@payload-config'
import { notFound } from 'next/navigation'

// Generar páginas estáticas para artículos publicados
export async function generateStaticParams() {
    const payload = await getPayloadHMR({ config: configPromise })
    const posts = await payload.find({
        collection: 'posts',
        where: { estado: { equals: 'publicado' } },
        select: { slug: true },
    })
    
    return posts.docs.map(post => ({ slug: post.slug }))
}

// Página del artículo
export default async function BlogPost({ params }: { params: { slug: string } }) {
    const payload = await getPayloadHMR({ config: configPromise })
    
    const posts = await payload.find({
        collection: 'posts',
        where: {
            slug: { equals: params.slug },
            estado: { equals: 'publicado' },
        },
        depth: 2, // Poblar relaciones hasta 2 niveles
    })
    
    if (!posts.docs[0]) notFound()
    
    const post = posts.docs[0]
    
    return (
        <article>
            <h1>{post.titulo}</h1>
            <p>Por {post.autor.nombre}</p>
            {/* Renderizar rich text content */}
        </article>
    )
}

Solución de Problemas

Error de tipos TypeScript

# Regenerar los tipos después de cambiar colecciones
npm run generate:types

# Los tipos se guardan en src/payload-types.ts
# Importar en tu código:
# import type { Post, User } from '../payload-types'

La base de datos no conecta

# Verificar que MongoDB/PostgreSQL está corriendo
systemctl status mongod

# Verificar la URI de conexión en .env
cat .env | grep DATABASE_URI

# Probar la conexión manualmente
mongosh "mongodb://localhost:27017/mi-cms" --eval "db.runCommand({ping: 1})"

El panel de administración no carga

# Limpiar la caché de Next.js y reconstruir
rm -rf .next/
npm run build
npm start

# En desarrollo, reiniciar el servidor
# Ctrl+C y npm run dev

Error de permisos de acceso

# Verificar que el token incluye el usuario correcto
curl http://localhost:3000/api/users/me \
    -H "Authorization: Bearer TU_TOKEN"

# Verificar la lógica de access control en la colección
# Añadir console.log temporales en las funciones de acceso para depurar

Conclusión

Payload CMS es la opción ideal para equipos que quieren un headless CMS totalmente tipado y configurado como código, con la posibilidad de colocarlo directamente dentro de un proyecto Next.js. Su enfoque de "config as code" facilita el versionado con Git, la colaboración en equipo y la reproducibilidad entre entornos, mientras que los hooks y el sistema de acceso granular permiten modelar cualquier lógica de negocio compleja sin salir del CMS.