KeystoneJS — CMS Headless y Framework de Aplicaciones
Guia experta para KeystoneJS, el CMS headless open-source construido sobre Node.js, GraphQL y Prisma. Cubre definicion de esquemas de contenido, interfaces de administracion, control de acceso por roles y APIs GraphQL personalizadas.
Descarga abierta · sin registro · para Node.js, GraphQL, Prisma
/**
- CULTIVA IA — CMS Headless con KeystoneJS
- ==========================================
- Blog editorial de IA aplicada al marketing.
- Stack: Node.js + KeystoneJS 6 + Prisma + PostgreSQL (Supabase)
- Tipos de contenido:
- Articulo, Categoria, Autor, Newsletter, Herramienta
- Roles: admin | editor | author
- Despliegue: API en Railway · Frontend Next.js 14 en Vercel */
import { config, list } from "@keystone-6/core"; import { allowAll, denyAll } from "@keystone-6/core/access"; import { text, relationship, timestamp, select, integer, image, checkbox, password, float, url, } from "@keystone-6/core/fields"; import { document } from "@keystone-6/fields-document"; import type { Lists, ListAccessArgs } from "@keystone-6/core/types"; import { graphql } from "@keystone-6/core";
// ───────────────────────────────────────────── // 1. CONTROL DE ACCESO (RBAC) // ─────────────────────────────────────────────
function isSignedIn({ session }: ListAccessArgs) { return Boolean(session); }
function isAdmin({ session }: ListAccessArgs) { return session?.data?.role === "admin"; }
function isAdminOrEditor({ session }: ListAccessArgs) { return ["admin", "editor"].includes(session?.data?.role ?? ""); }
/**
- Acceso para Articulos:
- Lectura pública de publicados
- Creación: cualquier usuario autenticado
- Edición: admin todo · editor todo · author solo los suyos
- Borrado: solo admin */ const articuloAccess = { operation: { query: allowAll, create: isSignedIn, update: isSignedIn, delete: isAdmin, }, filter: { query: ({ session }: ListAccessArgs) => { if (!session) return { status: { equals: "published" } }; if (session.data?.role === "admin") return {}; if (session.data?.role === "editor") return {}; // author: solo ve sus propios + los publicados de otros return { OR: [ { status: { equals: "published" } }, { autor: { id: { equals: session.itemId } } }, ], }; }, update: ({ session }: ListAccessArgs) => { if (!session) return false; if (["admin", "editor"].includes(session.data?.role)) return {}; return { autor: { id: { equals: session.itemId } } }; }, }, };
// ───────────────────────────────────────────── // 2. ESQUEMAS DE CONTENIDO // ─────────────────────────────────────────────
const lists: Lists = { // ── Categoría ───────────────────────────── Categoria: list({ access: { operation: { query: allowAll, create: isAdminOrEditor, update: isAdminOrEditor, delete: isAdmin, }, }, ui: { label: "Categorías", singular: "Categoría", plural: "Categorías", listView: { initialColumns: ["nombre", "slug", "articulos"] }, }, fields: { nombre: text({ validation: { isRequired: true }, isIndexed: "unique", }), slug: text({ isIndexed: "unique", hooks: { resolveInput({ resolvedData, item }) { if (!resolvedData.slug && resolvedData.nombre) { return (resolvedData.nombre as string) .toLowerCase() .normalize("NFD") .replace(/[̀-ͯ]/g, "") .replace(/[^a-z0-9]+/g, "-") .replace(/^-|-$/g, ""); } return resolvedData.slug ?? item?.slug ?? ""; }, }, }), descripcion: text({ ui: { displayMode: "textarea" } }), color: text({ defaultValue: "#6366f1" }), articulos: relationship({ ref: "Articulo.categorias", many: true }), }, }),
// ── Autor ────────────────────────────────── Autor: list({ access: { operation: { query: allowAll, create: isAdmin, update: isAdminOrEditor, delete: isAdmin, }, }, ui: { label: "Autores", listView: { initialColumns: ["nombre", "email", "rol"] }, }, fields: { nombre: text({ validation: { isRequired: true } }), email: text({ validation: { isRequired: true }, isIndexed: "unique" }), password: password({ validation: { isRequired: true, length: { min: 8 } } }), rol: select({ options: [ { label: "Administrador", value: "admin" }, { label: "Editor", value: "editor" }, { label: "Autor", value: "author" }, ], defaultValue: "author", ui: { displayMode: "segmented-control" }, }), bio: text({ ui: { displayMode: "textarea" } }), avatar: image({ storage: "localImages" }), twitter: text({ label: "@handle de Twitter" }), linkedin: url(), activo: checkbox({ defaultValue: true }), articulos: relationship({ ref: "Articulo.autor", many: true }), }, }),
// ── Artículo ───────────────────────────────
Articulo: list({
access: articuloAccess,
ui: {
label: "Artículos",
listView: { initialColumns: ["titulo", "estado", "autor", "publicadoEn"] },
},
hooks: {
resolveInput: {
create: async ({ resolvedData, context }) => {
// Auto-asignar autor al usuario actual
if (!resolvedData.autor && context.session) {
resolvedData.autor = { connect: { id: context.session.itemId } };
}
return resolvedData;
},
},
validateInput: async ({ resolvedData, addValidationError }) => {
if (resolvedData.estado === "published" && !resolvedData.contenido) {
addValidationError("No puedes publicar un artículo sin contenido.");
}
if (resolvedData.estado === "published" && !resolvedData.imagenDestacada) {
addValidationError("Los artículos publicados deben tener imagen destacada.");
}
},
afterOperation: {
update: async ({ item, originalItem }) => {
// Revalidar ISR en Next.js cuando se publica
if (originalItem.estado !== "published" && item.estado === "published") {
try {
await fetch(${process.env.FRONTEND_URL}/api/revalidate, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
secret: process.env.REVALIDATION_SECRET,
paths: [/blog/${item.slug}, "/blog", "/"],
}),
});
console.log([ISR] Revalidado: /blog/${item.slug});
} catch (err) {
console.error("[ISR] Error al revalidar:", err);
}
}
},
},
},
fields: {
titulo: text({ validation: { isRequired: true } }),
slug: text({
isIndexed: "unique",
hooks: {
resolveInput({ resolvedData, item }) {
if (!resolvedData.slug && resolvedData.titulo) {
return (resolvedData.titulo as string)
.toLowerCase()
.normalize("NFD")
.replace(/[̀-ͯ]/g, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "");
}
return resolvedData.slug ?? item?.slug ?? "";
},
},
}),
extracto: text({
validation: { length: { max: 280 } },
ui: { displayMode: "textarea" },
}),
estado: select({
options: [
{ label: "Borrador", value: "draft" },
{ label: "En revisión", value: "review" },
{ label: "Publicado", value: "published" },
{ label: "Archivado", value: "archived" },
],
defaultValue: "draft",
ui: { displayMode: "segmented-control" },
}),
contenido: document({
formatting: true,
links: true,
dividers: true,
layouts: [
[1, 1],
[1, 1, 1],
[2, 1],
],
}),
imagenDestacada: image({ storage: "localImages" }),
publicadoEn: timestamp(),
tiempoLectura: integer({
label: "Tiempo de lectura (min)",
ui: { createView: { fieldMode: "hidden" } },
hooks: {
resolveInput({ resolvedData }) {
if (resolvedData.contenido) {
const palabras = JSON.stringify(resolvedData.contenido).split(/\s+/).length;
return Math.max(1, Math.ceil(palabras / 200));
}
return undefined;
},
},
}),
destacado: checkbox({ defaultValue: false, label: "Artículo destacado (home)" }),
autor: relationship({
ref: "Autor.articulos",
ui: { displayMode: "cards", cardFields: ["nombre", "email"] },
}),
categorias: relationship({
ref: "Categoria.articulos",
many: true,
ui: { displayMode: "select" },
}),
herramientas: relationship({
ref: "Herramienta.articulos",
many: true,
label: "Herramientas mencionadas",
}),
},
}),
// ── Newsletter ─────────────────────────────
Newsletter: list({
access: {
operation: {
query: isAdminOrEditor,
create: isAdminOrEditor,
update: isAdminOrEditor,
delete: isAdmin,
},
},
ui: {
label: "Newsletters",
listView: { initialColumns: ["asunto", "estado", "enviadoEn"] },
},
hooks: {
afterOperation: {
update: async ({ item, originalItem }) => {
// Disparar webhook a Resend cuando se marca como "enviado"
if (originalItem.estado !== "sent" && item.estado === "sent") {
try {
await fetch("https://api.resend.com/emails", {
method: "POST",
headers: {
"Authorization": Bearer ${process.env.RESEND_API_KEY},
"Content-Type": "application/json",
},
body: JSON.stringify({
from: "CULTIVA IA hola@cultivaia.com",
to: ["lista@cultivaia.com"],
subject: item.asunto,
html: item.cuerpoHtml,
}),
});
console.log([Resend] Newsletter enviada: ${item.asunto});
} catch (err) {
console.error("[Resend] Error al enviar newsletter:", err);
}
}
},
},
},
fields: {
asunto: text({ validation: { isRequired: true } }),
estado: select({
options: [
{ label: "Borrador", value: "draft" },
{ label: "Listo para enviar", value: "ready" },
{ label: "Enviado", value: "sent" },
],
defaultValue: "draft",
ui: { displayMode: "segmented-control" },
}),
cuerpoHtml: text({ ui: { displayMode: "textarea" } }),
articulosIncluidos: relationship({
ref: "Articulo",
many: true,
label: "Artículos incluidos",
}),
enviadoEn: timestamp(),
},
}),
// ── Herramienta ──────────────────────────── Herramienta: list({ access: { operation: { query: allowAll, create: isAdminOrEditor, update: isAdminOrEditor, delete: isAdmin, }, }, ui: { label: "Herramientas IA", listView: { initialColumns: ["nombre", "categoria", "valoracion", "activa"] }, }, fields: { nombre: text({ validation: { isRequired: true }, isIndexed: "unique" }), descripcion: text({ ui: { displayMode: "textarea" } }), sitioWeb: url({ label: "Sitio web" }), logo: image({ storage: "localImages" }), categoria: select({ options: [ { label: "IA Generativa", value: "generative_ai" }, { label: "Automatización", value: "automation" }, { label: "Analítica", value: "analytics" }, { label: "Contenido", value: "content" }, { label: "CRM", value: "crm" }, { label: "Diseño", value: "design" }, { label: "SEO", value: "seo" }, { label: "Publicidad", value: "advertising" }, { label: "Email Marketing", value: "email" }, { label: "Otros", value: "other" }, ], ui: { displayMode: "select" }, }), valoracion: float({ label: "Valoración (0-5)", defaultValue: 0 }), esGratuita: checkbox({ defaultValue: false, label: "¿Tiene plan gratuito?" }), precioDesde: float({ label: "Precio desde (€/mes)" }), activa: checkbox({ defaultValue: true }), articulos: relationship({ ref: "Articulo.herramientas", many: true, label: "Aparece en artículos", }), }, }), };
// ───────────────────────────────────────────── // 3. EXTENSIONES GRAPHQL PERSONALIZADAS // ─────────────────────────────────────────────
const extendGraphqlSchema = graphql.extend((base) => ({ query: { /** * featuredArticles — Artículos destacados para el hero de la home * Uso: query { featuredArticles(limit: 3) { id titulo slug extracto autor { nombre } } } */ featuredArticles: graphql.field({ type: graphql.list(graphql.nonNull(base.object("Articulo"))), args: { limit: graphql.arg({ type: graphql.Int, defaultValue: 6 }), categoria: graphql.arg({ type: graphql.String }), }, async resolve(_source, { limit, categoria }, context) { const where: Record<string, unknown> = { estado: { equals: "published" }, destacado: { equals: true }, }; if (categoria) { where.categorias = { some: { slug: { equals: categoria } } }; } return context.db.Articulo.findMany({ where, orderBy: { publicadoEn: "desc" }, take: limit ?? 6, }); }, }),
/**
* blogStats — Estadísticas del blog para el dashboard de admin
*/
blogStats: graphql.field({
type: graphql.object("BlogStats")({
fields: {
totalArticulos: graphql.field({ type: graphql.Int }),
totalPublicados: graphql.field({ type: graphql.Int }),
totalAutores: graphql.field({ type: graphql.Int }),
totalCategorias: graphql.field({ type: graphql.Int }),
totalHerramientas: graphql.field({ type: graphql.Int }),
},
}),
async resolve(_source, _args, context) {
const [total, publicados, autores, categorias, herramientas] =
await Promise.all([
context.db.Articulo.count({}),
context.db.Articulo.count({ where: { estado: { equals: "published" } } }),
context.db.Autor.count({ where: { activo: { equals: true } } }),
context.db.Categoria.count({}),
context.db.Herramienta.count({ where: { activa: { equals: true } } }),
]);
return {
totalArticulos: total,
totalPublicados: publicados,
totalAutores: autores,
totalCategorias: categorias,
totalHerramientas: herramientas,
};
},
}),
/**
* articulosByCategoria — Feed filtrado para páginas de categoría
*/
articulosByCategoria: graphql.field({
type: graphql.list(graphql.nonNull(base.object("Articulo"))),
args: {
slugCategoria: graphql.arg({ type: graphql.nonNull(graphql.String) }),
limit: graphql.arg({ type: graphql.Int, defaultValue: 12 }),
offset: graphql.arg({ type: graphql.Int, defaultValue: 0 }),
},
async resolve(_source, { slugCategoria, limit, offset }, context) {
return context.db.Articulo.findMany({
where: {
estado: { equals: "published" },
categorias: { some: { slug: { equals: slugCategoria } } },
},
orderBy: { publicadoEn: "desc" },
take: limit ?? 12,
skip: offset ?? 0,
});
},
}),
},
mutation: { /** * publicarArticulo — Publica un borrador con timestamp automático */ publicarArticulo: graphql.field({ type: base.object("Articulo"), args: { id: graphql.arg({ type: graphql.nonNull(graphql.ID) }) }, async resolve(_source, { id }, context) { return context.db.Articulo.updateOne({ where: { id }, data: { estado: "published", publicadoEn: new Date().toISOString(), }, }); }, }),
/**
* archivarArticulo — Archiva un artículo publicado
*/
archivarArticulo: graphql.field({
type: base.object("Articulo"),
args: { id: graphql.arg({ type: graphql.nonNull(graphql.ID) }) },
async resolve(_source, { id }, context) {
return context.db.Articulo.updateOne({
where: { id },
data: { estado: "archived" },
});
},
}),
}, }));
// ───────────────────────────────────────────── // 4. CONFIGURACIÓN PRINCIPAL // ─────────────────────────────────────────────
export default config({ db: { provider: "postgresql", url: process.env.DATABASE_URL!, shadowDatabaseUrl: process.env.SHADOW_DATABASE_URL, enableLogging: process.env.NODE_ENV === "development", },
lists,
extendGraphqlSchema,
storage: {
localImages: {
kind: "local",
type: "image",
generateUrl: (path) => /imagenes${path},
serverRoute: { path: "/imagenes" },
storagePath: "public/imagenes",
},
},
session: { maxAge: 60 * 60 * 24 * 30, // 30 días secret: process.env.SESSION_SECRET!, },
ui: { // Solo mostrar Admin UI en desarrollo o a usuarios autenticados isAccessAllowed: (context) => Boolean(context.session), },
server: { cors: { origin: [ process.env.FRONTEND_URL ?? "http://localhost:3000", "https://cultivaia.com", "https://www.cultivaia.com", ], credentials: true, }, port: Number(process.env.PORT ?? 8080), },
graphql: { path: "/api/graphql", playground: process.env.NODE_ENV === "development", apolloConfig: { introspection: process.env.NODE_ENV === "development", }, }, });
// ───────────────────────────────────────────── // 5. EJEMPLO DE QUERIES GRAPHQL (referencia) // ─────────────────────────────────────────────
/*
Hero de la home — artículos destacados
query HeroHome { featuredArticles(limit: 3) { id titulo slug extracto tiempoLectura imagenDestacada { url width height } autor { nombre avatar { url } } categorias { nombre slug color } } }
Feed paginado de categoría "automatizacion"
query FeedAutomatizacion { articulosByCategoria(slugCategoria: "automatizacion", limit: 12, offset: 0) { id titulo slug extracto publicadoEn tiempoLectura autor { nombre } categorias { nombre color } } }
Estadísticas del dashboard
query DashboardStats { blogStats { totalArticulos totalPublicados totalAutores totalCategorias totalHerramientas } }
Publicar un borrador
mutation PublicarArticulo { publicarArticulo(id: "clxyz12345") { id titulo estado publicadoEn } } */
// qué_hace
Proporciona guia tecnica completa para construir un CMS headless con KeystoneJS, incluyendo esquemas, control de acceso, GraphQL personalizado y hooks de ciclo de vida.
// cómo_lo_hace
Ofrece ejemplos de codigo TypeScript listos para produccion cubriendo configuracion del esquema, reglas RBAC, extensiones GraphQL y webhooks de revalidacion de contenido.
// ejemplo_de_uso
Úsala como guía de cabecera cuando construyes un CMS headless con KeystoneJS y necesitas ejemplos listos para producción. Ej.: implementas control de acceso RBAC con roles editor/admin y webhooks de revalidación para tu frontend Next.js.
// plataformas
// opiniones_de_la_comunidad
Opiniones
Cargando opiniones…
// pase_cultiva_ia
Llévate todo el arsenal con el Pase
Todas las skills, prompts y automatizaciones del catálogo en un único archivo, listas para usar: un pago, acceso de por vida y las novedades que añadamos. Sin suscripción.
Pago único · IVA incluido · pago seguro con Stripe.
Acceso inmediato · si no es lo que esperabas, te devolvemos los 10 €.