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.


