Ir al contenido

Instalación

  • Node 24.2 o superior.
  • Una cuenta de Cloudflare.
  • Un proyecto Astro con el adaptador de Cloudflare. Si no lo tienes, cms init lo crea: solo hace falta si vas a cablear un proyecto que ya existe.
  • Un token de GitHub con permiso read:packages — el paquete se publica en GitHub Packages, y GitHub Packages exige autenticación también para leer, aunque el paquete sea público. Se genera en Settings → Developer settings → Personal access tokens.

Esto va antes del comando, y no dentro del proyecto: init tiene que saber de dónde bajar el paquete, y el proyecto todavía no existe. Son dos líneas en tu ~/.npmrc —el global de tu usuario— y la variable exportada:

~/.npmrc
@kevolution-co:registry=https://npm.pkg.github.com
//npm.pkg.github.com/:_authToken=${GITHUB_TOKEN}
Ventana de terminal
export GITHUB_TOKEN=ghp_

${GITHUB_TOKEN} es texto literal dentro del fichero: lo expande npm desde tu entorno cada vez que lo lee, así que el token no queda escrito en ningún sitio. Sin esto, el comando de abajo falla con un 401.

Ventana de terminal
pnpm create @kevolution-co/cms mi-sitio --cloud

Con npm, npm create @kevolution-co/cms -- mi-sitio --cloud —el -- es de npm, que si no se queda los flags para sí—; con yarn y bun, yarn create @kevolution-co/cms … y bun create @kevolution-co/cms ….

Un comando, y al terminar tienes un proyecto Astro sobre Cloudflare con el CMS cableado, las dependencias instaladas, el secreto de sesión puesto y la primera migración aplicada en local.

create resuelve el paquete de arranque @kevolution-co/create-cms, que es cms init y nada más: sin peers y sin un solo build script, así que instala limpio con pnpm 10 o superior. Es lo que pnpm dlx @kevolution-co/cms init no podía hacer —arrastraba las peers de la librería y pnpm se paraba en ERR_PNPM_IGNORED_BUILDS antes de ejecutar nada; ver Solución de problemas—. El resto de comandos viven en @kevolution-co/cms, que init deja instalado en el proyecto.

La diferencia entre los dos modos son los cuatro wrangler … create, y nada más:

  • --cloud crea en tu cuenta de Cloudflare la base D1, el bucket de R2 y los dos namespaces de KV, y deja los ids que Cloudflare emite escritos en el wrangler.jsonc.
  • --local no toca tu cuenta. El proyecto sirve para pnpm dev, cms db:migrate y cms db:apply en local —que no necesitan ids—, pero no se puede desplegar hasta que los subas con cms resources:create.

El wrangler.jsonc nombra el bucket de R2 desde el primer momento, en los dos modos, porque R2 no tiene id: su identidad es ese nombre. Que el bucket exista en tu cuenta es otra cosa, y solo la sabe Cloudflare: cms resources:create se la pregunta antes de crear nada, así que lo crea si falta y no hace nada si ya está (#173).

Si no pasas ninguno de los dos y hay terminal, el comando lo pregunta. Si no la hay —CI, o un agente—, exige uno de los dos y sale sin escribir nada: crear recursos de pago en una cuenta ajena no es una respuesta que se pueda dar por omisión.

Para que reconozcas tu proyecto al abrirlo:

Fichero Qué lleva
.npmrc Las dos líneas del registro, con ${GITHUB_TOKEN}. Va commiteado: lo necesita el build de Cloudflare
package.json @kevolution-co/cms y sus peers —drizzle-orm, de tiempo de ejecución; drizzle-kit, de desarrollo— a la versión exacta que el paquete declara
cms.config.ts Una colección posts de ejemplo y los valores de arranque del correo
drizzle.config.ts sqlite, el schema en ./.cms/schema.ts y la salida en ./migrations
astro.config.mjs output: 'server', el adaptador de Cloudflare, React y la integración del CMS
wrangler.jsonc Los cinco bloques del CMS: D1, R2, los dos KV, send_email, nodejs_compat, migrations_pattern y assets.directory: "./dist/client"
.gitignore .cms y .dev.vars
tsconfig.json ./.cms/types.d.ts dentro del include
.dev.vars BETTER_AUTH_SECRET, generado con randomBytes(32). El valor no se imprime nunca
migrations/ La primera migración, generada y aplicada en la D1 local

No sobrescribe nada de lo que ya exista. Al terminar imprime qué escribió y qué se saltó porque ya estaba, así que volver a ejecutarlo no cambia ni un byte. Si algo queda a medias, lo dice en amarillo al final: cms doctor te enumera exactamente qué falta y con qué comando se arregla.

Los ocho flags —--cloud, --local, --name, --pm, --skip-create, --no-install, --no-migrate y --yes— están en la referencia del CLI.

Ventana de terminal
pnpm dev

Ya puedes leer contenido desde una plantilla, con los tipos de tus colecciones:

src/pages/index.astro
---
export const prerender = false
const { docs } = await Astro.locals.cms.collections.posts.find({ limit: 5 })
---
<ul>{docs.map((post) => <li>{post.title}</li>)}</ul>

prerender = false es imprescindible en cualquier página que lea contenido: en build no hay bindings de Cloudflare y no hay nada que leer.

También responden /api/cms/posts —los seis endpoints de la API REST— y /admin, que en una instalación vacía te lleva al asistente de configuración: pide los ajustes del sitio y la primera cuenta, y con eso cierra el registro.

Enviar correo transaccional —verificación de cuenta, recuperación de contraseña— exige tres cosas:

  1. Un plan Workers de pago. Email Sending no está en el gratuito.
  2. Un dominio que use DNS de Cloudflare, dado de alta en Compute → Email Service → Email Sending.
  3. Que la dirección remitente pertenezca a ese dominio.

Y un bloque email en tu config, con esa dirección:

cms.config.ts
import { defineConfig } from '@kevolution-co/cms'
export default defineConfig({
email: { from: 'noreply@tudominio.com', siteName: 'Mi sitio' },
collections: [],
})

Este bloque son los valores de arranque. Los mismos tres se pueden cambiar después sin desplegar desde los ajustes del sitio, que viven en KV.

En desarrollo local no hace falta nada de esto: wrangler dev simula el envío y vuelca el contenido del mensaje a un fichero cuya ruta imprime en la consola. Un from cualquiera vale mientras no sea vacío.

Si añades "remote": true al binding pierdes esa simulación y empiezas a enviar correo real desde tu máquina, con los tres requisitos de arriba ya cumplidos. Déjalo fuera mientras desarrolles.

Los detalles están en Email.

Desde Astro 7 y @astrojs/cloudflare 14, Astro.locals.runtime.env ya no existe. Los bindings se importan:

import { env } from 'cloudflare:workers'

El CMS lo hace por ti; solo lo necesitas si quieres acceder a un binding directamente.

Esta es la misma instalación paso a paso, y hace falta cuando cms init no puede hacerla por ti: un proyecto Astro que ya existe y que el comando no sabe cablear sin riesgo —una astro.config.mjs con una forma que no reconoce, un monorepo, un árbol con convenciones propias—. También sirve para saber qué es cada cosa de las que el comando escribe.

Al terminar, cms doctor verifica el resultado paso a paso y dice qué falta y cómo arreglarlo.

  1. Crea la aplicación con la CLI de Cloudflare, eligiendo Astro y la plantilla en blanco:

    Ventana de terminal
    pnpm create cloudflare@latest mi-sitio --framework=astro
  2. Añade React, que es lo que usa el administrador:

    Ventana de terminal
    pnpm astro add react
  3. Apunta el scope @kevolution-co a GitHub Packages, con un .npmrc en la raíz del proyecto:

    .npmrc
    @kevolution-co:registry=https://npm.pkg.github.com
    //npm.pkg.github.com/:_authToken=${GITHUB_TOKEN}

    ${GITHUB_TOKEN} lo expande npm desde tu entorno al leer el fichero, así que el token no queda escrito en el repositorio. Sin este paso el add del punto siguiente falla con un 401.

    Este fichero va commiteado: el build de Cloudflare lo necesita para instalar el paquete. Ver Despliegue.

  4. Exporta el token e instala el CMS y drizzle-orm:

    Ventana de terminal
    export GITHUB_TOKEN=ghp_
    pnpm add @kevolution-co/cms drizzle-orm@1.0.0-rc.4

    drizzle-orm va en el mismo comando y sin -D: es una peer del paquete y es de tiempo de ejecución, no de desarrollo —el CMS la importa para hablar con D1, y el .cms/schema.ts que genera la importa también—. Y va con la versión, porque la peer es exacta: pedirla a secas te trae el dist-tag latest, que todavía es de la línea 0.45. pnpm peers check se queja de las dos cosas —unmet peer si la instalas a secas, missing peer si no la instalas—, y cuando falta del todo el primer comando del CMS se para en Cargando config con un Cannot find module 'drizzle-orm'.

Cada comando imprime un identificador que hay que pegar en wrangler.jsonc.

Ventana de terminal
npx wrangler d1 create mi-sitio
npx wrangler r2 bucket create mi-sitio
npx wrangler kv namespace create KV
npx wrangler kv namespace create SESSION
wrangler.jsonc
{
"compatibility_flags": ["nodejs_compat"],
"d1_databases": [
{
"binding": "DB",
"database_name": "mi-sitio",
"database_id": "<id>",
"migrations_dir": "migrations",
"migrations_pattern": "migrations/*/migration.sql"
}
],
"r2_buckets": [{ "binding": "R2", "bucket_name": "mi-sitio" }],
"kv_namespaces": [
{ "binding": "SESSION", "id": "<id>" },
{ "binding": "KV", "id": "<id>" }
],
"send_email": [{ "name": "EMAIL" }]
}

Y el secreto con el que se firman las sesiones, que no es un binding sino una variable:

Ventana de terminal
pnpm cms secret:set

Lo genera con randomBytes(32) y lo escribe en .dev.vars sin pisar nada de lo que ese fichero ya tenga. El valor no se imprime: lo que dice el comando es dónde quedó.

En producción, pnpm cms secret:set --remote. .dev.vars va en el .gitignore. Los detalles, en Autenticación.

cms.config.ts
import { defineCollection, defineConfig } from '@kevolution-co/cms'
export default defineConfig({
collections: [
defineCollection({
slug: 'posts',
admin: { useAsTitle: 'title' },
fields: [
{ name: 'title', type: 'text', required: true },
{ name: 'slug', type: 'text', required: true, unique: true, index: true },
{ name: 'body', type: 'textarea' },
],
}),
],
})
astro.config.mjs
import cloudflare from '@astrojs/cloudflare'
import react from '@astrojs/react'
import cms from '@kevolution-co/cms/astro'
import { defineConfig } from 'astro/config'
import cmsConfig from './cms.config'
export default defineConfig({
output: 'server',
adapter: cloudflare(),
integrations: [react(), cms(cmsConfig)],
})

output: 'server', el adaptador de Cloudflare y @astrojs/react son obligatorios: la integración comprueba los tres al arrancar y falla nombrando el que falte. La referencia completa —opciones, rutas inyectadas y qué pone en Astro.locals.cms— está en Integración de Astro.

Tu configuración no crea tablas por sí sola: produce migraciones SQL que revisas antes de aplicar.

Instala drizzle-kit, que es quien escribe el SQL, y añade su configuración:

Ventana de terminal
pnpm add -D drizzle-kit@1.0.0-rc.4

La versión va fijada, y no sobra: el CMS la declara como peer, y sin fijarla te llevas la última que publica drizzle-kit, que es todavía de la línea 0.31 y no la satisface. Esta sí va con -D, al revés que drizzle-orm: drizzle-kit solo escribe el SQL en tu máquina y no entra en lo que despliegas.

drizzle.config.ts
import { defineConfig } from 'drizzle-kit'
export default defineConfig({
dialect: 'sqlite',
schema: './.cms/schema.ts',
out: './migrations',
})

Genera:

Ventana de terminal
pnpm cms db:migrate
✔ Cargando config
✔ Emitiendo .cms/
3 colecciones desde /mi-sitio/cms.config.ts
/mi-sitio/.cms/schema.ts
/mi-sitio/.cms/types.d.ts

Son tres colecciones y tú solo has declarado una. Las otras dos las añade el CMS por su cuenta: users, la tabla de usuarios de la autenticación, y ssd, la biblioteca de medios del panel.

Lee el SQL que ha salido en migrations/ y, si estás conforme, aplícalo:

Ventana de terminal
pnpm cms db:apply
Aplicando en local sobre el binding DB
🚣 13 commands executed successfully.

No hace falta que le digas cuál es la base: la saca del binding de tu wrangler.jsonc. Si el SQL no te convence, pnpm cms db:pop borra esa migración y vuelves a empezar.

Añade .cms/ a tu .gitignore —se regenera siempre— y commitea migrations/.

La integración vuelve a generar .cms/ en cada arranque y vigila tu cms.config.ts, así que en desarrollo no tienes que llamar a db:generate a mano. Lo que nunca es automático es generar o aplicar una migración: eso siempre lo pides tú.

Por último, deja que TypeScript vea los tipos generados:

tsconfig.json
{
"include": [
".astro/types.d.ts",
"./.cms/types.d.ts",
"**/*"
]
}

Hay que nombrarlo: un comodín no entra en un directorio cuyo nombre empieza por punto. Con esa línea, Astro.locals.cms.collections.posts queda tipado con tus colecciones sin que declares nada más.

El ciclo completo, con el aviso de deriva y lo que SQLite no te deja hacer, está en Schema y migraciones.