← Volver al catálogo
WebReferenciaAvanzadoGratis

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.

Descargar SKILL.md

Descarga abierta · sin registro · para Node.js, GraphQL, Prisma

// resultado_de_ejemplo

/**

  • 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

Node.jsGraphQLPrismaPostgreSQLSQLiteMySQL
Categoría
Web
Tipo
Referencia
Nivel
Avanzado
Licencia
Apache-2.0
Seguridad
seguro · riesgo alto
Versión
1.0.0

// 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 €.