---
title: "Tutorial de EmDash CMS: Crea tu Primer Sitio Paso a Paso"
description: "Tutorial de EmDash CMS paso a paso: instala, genera el proyecto y configura tu primer sitio con el panel de administración, passkeys y tu primera query getEmDashCollection."
date: 2026-09-29
categories: ["web-development"]
tags: ["emdash","astro","npm"]
---

import Button from "@components/widgets/Button.astro";
import Notice from "@components/widgets/Notice.astro";
import ListCheck from "@components/widgets/ListCheck.astro";
import Accordion from "@components/widgets/Accordion.astro";
import Tabs from "@components/widgets/Tabs.astro";
import Tab from "@components/widgets/Tab.astro";

Este tutorial de EmDash CMS te lleva de un terminal en blanco a un post publicado: instala los requisitos, genera un proyecto con `npm create emdash`, recorre el asistente de configuración y escribe tu primera query `getEmDashCollection`. Empezar lleva de 15 a 30 minutos y no cuesta nada. Todo corre en tu máquina.

EmDash es un CMS full-stack en TypeScript construido sobre Astro, publicado por Cloudflare bajo MIT. `emdash@1.0.1` es la versión con la que se escribió esta guía (finales de septiembre de 2026; ejecuta `npm view emdash version` antes de empezar, el proyecto avanza rápido). Un solo despliegue te da el sitio, el panel de administración en `/_emdash/admin`, una REST API, auth con passkeys y una biblioteca de medios. Si aún estás decidiendo si dejar WordPress, lee primero [los costes reales y los compromisos operativos de Astro vs WordPress](/astro-vs-wordpress/). Para ver cómo se sitúa EmDash frente a otras opciones nativas de Astro, consulta [cómo se compara EmDash con otros headless CMS para Astro](/best-headless-cms-for-astro/).

Sin cuenta de Cloudflare. Sin Docker. Sin servidor de base de datos que vigilar. Solo Node.js y SQLite en tu portátil.

## Qué vas a construir en este tutorial de EmDash CMS


<YouTubeEmbed
  url="https://www.youtube.com/embed/1X6KwMjbv-Y"
  label="WordPress Is Broken. Can emdash Replace It?"
/>

Estado final: un sitio Astro local con el panel de administración de EmDash integrado, contenido de ejemplo editado y publicado, y una query `getEmDashCollection("posts")` funcionando en la página de inicio. Entenderás qué hace cada archivo, para que nada del proyecto generado te parezca magia.

![Hoja de ruta del tutorial: scaffold, servidor de desarrollo, asistente de configuración, tour del admin, publicar una edición, anatomía de la config, primera query](@assets/images/26/09/tutorial-roadmap.svg)

Esta es la Parte 2 de una serie. [Nuestra reseña de EmDash CMS tras seis meses](/es/emdash-cms-resena/) cubre qué cambió en la 1.0 y para quién es realmente el CMS. Este tutorial se queda en la ruta local sin cuenta cloud (Node.js + SQLite + uploads locales). El despliegue viene después: Cloudflare Workers con D1 y R2 en la Parte 3, y un VPS con Docker en la Parte 4.

**Coste de este tutorial: 0 $.** Sin cuenta cloud, sin tier de pago, sin periodo de prueba. El estado vive en tres rutas locales: `data.db`, `uploads/` y `.env`. Borra la carpeta y vuelves a cero.

<Button text="Lee la reseña de EmDash CMS" link="/es/emdash-cms-resena/" variant="outline" color="blue" size="md" icon="arrow-right" />

## Prerequisitos: qué necesitas para instalar EmDash CMS

Antes de instalar las herramientas de EmDash CMS, marca esta lista. Los requisitos son pocos, pero uno de ellos es un suelo innegociable. (La [documentación oficial de primeros pasos](https://docs.emdashcms.com/getting-started/) lista el mismo suelo; esta guía añade los prompts, el tour del admin y los modos de fallo.)

<ListCheck>
<ul>
<li>Node.js v22.16.0 o superior (requisito obligatorio)</li>
<li>npm 10+ (o Bun 1.x; ambas rutas se muestran abajo)</li>
<li>Un navegador compatible con passkeys: Chrome, Edge, Safari o Firefox</li>
<li>~15-30 minutos y ~500 MB de disco libre para `node_modules`</li>
<li>Sin cuenta cloud (SQLite más archivos locales son suficientes)</li>
</ul>
</ListCheck>

El paquete `emdash` ocupa unos 23 MB descomprimido en unos 2.500 archivos, así que la primera instalación de dependencias es más lenta que en un proyecto Astro típico. Es lo esperado. No necesitas cuenta de Cloudflare, daemon de Docker, Postgres ni Redis. El puerto 4321 debería estar libre (Astro elige otro puerto si no lo está; más sobre esto en el Paso 2).

Si ya usas pnpm o yarn, el scaffolder también los soporta (`pnpm create emdash@latest`, `yarn create emdash`). El resto de la guía usa npm, con el equivalente en bun al lado.

### Comprueba tu versión de Node.js (22.16+)

```bash
node --version
```

Debe imprimir `v22.16.0` o posterior. En una distro con un Node por defecto más antiguo, no pelees con el gestor de paquetes; usa un gestor de versiones:

```bash
nvm install 22 && nvm use 22   # or: fnm install 22
```

**Verifica:** ejecuta `node --version` de nuevo. Si ves `v22.15.0` o cualquier cosa por debajo de `v22.16.0`, tanto el scaffolder como el servidor de desarrollo se quejarán después. Arréglalo ahora.

## Paso 1: genera un proyecto nuevo con `npm create emdash`

El scaffolder es `create-emdash`, y cualquier gestor de paquetes puede ejecutarlo. Yo usé npm para esta pasada, así que esa es la ruta por defecto aquí.

<Tabs>
<Tab name="npm">

```bash
npm create emdash@latest
```

</Tab>
<Tab name="Bun">

```bash
bun create emdash@latest
```

</Tab>
</Tabs>

**Ejecución del scaffolder (destino de despliegue Node.js, plantilla Blog, npm):**

```sh
— E M D A S H —

┌  Create a new EmDash project
│
◇  Project name? (use "." for current directory)
│  my-emdash-site
│
◇  Where will you deploy?
│  Node.js
│
◇  Which template?
│  Blog
│
◇  Which package manager?
│  npm
│
◇  Install dependencies?
│  Yes
│
◇  Project created!
│
●  Wrote EMDASH_ENCRYPTION_KEY to .env.
│
●  Installing dependencies with npm...

added 750 packages, and audited 751 packages in 38s

234 packages are looking for funding
  run `npm fund` for details

found 0 vulnerabilities
│
◆  Dependencies installed!
│
◇  Next steps ──────╮
│                   │
│  cd my-emdash-site  │
│  npm run dev        │
│                   │
├───────────────────╯
│

```

`pnpm create emdash@latest` y `yarn create emdash` funcionan igual y hacen las mismas preguntas. Responde a los prompts de la siguiente manera.

![Prompts del scaffolder npm create emdash para nombre de proyecto, destino de despliegue y plantilla](@assets/images/26/09/scaffolder-prompts.svg)

### Cada prompt del scaffolder, explicado

Cinco prompts, y solo dos importan para este tutorial.

1. **Project name.** `my-emdash-site` está bien. Se convierte en el nombre de la carpeta y del paquete npm.
2. **Where will you deploy?** Elige **Node.js**. Esta es la decisión que da forma al `astro.config.mjs` generado: el adaptador de Node en modo `standalone` más un archivo de base de datos SQLite. La opción Cloudflare conecta bindings de D1 y R2 en la config en su lugar; esa ruta es la Parte 3 de esta serie.

<Tabs>
<Tab name="Node.js (este tutorial)">

SQLite `data.db`, adaptador de Node, directorio local `uploads/`. Corre en un portátil hoy y en cualquier VPS después. Sin cuenta cloud en ningún momento.

</Tab>
<Tab name="Cloudflare">

La config generada apunta a bindings de D1 (base de datos) y R2 (medios), desplegados con Wrangler. Necesitas una cuenta de Cloudflare, y los plugins en sandbox te empujan a Workers Paid (unos 5 $/mes a septiembre de 2026, verifica el precio al desplegar). La guía completa es la Parte 3.

</Tab>
</Tabs>

3. **Which template?** Elige **Blog**. `create-emdash` incluye cinco plantillas: blog, marketing, portfolio, starter y blank. Blog siembra Posts, Pages, comentarios y posts de ejemplo. Es el punto de partida más rico para aprender el modelo de contenido, y es la plantilla detrás de cada captura de abajo. Starter es la variante recortada para proyectos posteriores. (El README de GitHub aún dice "three starter templates"; confía en la carpeta `templates/` del repo.)
4. **Which package manager?** Mantén el que el scaffolder haya detectado del comando que ejecutaste.
5. **Install dependencies?** Yes.

<Notice type="warning" title="Si falla la instalación de dependencias">
Los archivos del proyecto ya están escritos en ese punto, así que no se pierde nada. El scaffolder imprime un comando de reintento; ejecútalo dentro de la carpeta del proyecto y continúa. Borrar la carpeta y regenerar también funciona, solo cuesta otra descarga.
</Notice>

### Qué generó el scaffolder (árbol del proyecto)

![Árbol de archivos del proyecto EmDash generado mostrando astro.config.mjs, live.config.ts y la carpeta seed](@assets/images/26/09/project-tree.svg)

Las partes interesantes de `my-emdash-site/`:

- `astro.config.mjs`: output server, adaptador de Node, `react()` y la integración `emdash()` con una base de datos SQLite y almacenamiento de medios local. Diseccionado en el Paso 6.
- `src/live.config.ts`: conecta el contenido de EmDash con las Live Content Collections de Astro. Seis líneas. También Paso 6.
- `src/pages/index.astro`: la página de inicio generada. Ya llama a `getEmDashCollection("posts")`, que es el tema del Paso 7.
- `seed/seed.json`: el modelo de contenido, es decir, colecciones, campos y el contenido de ejemplo que aplicará el asistente de configuración.
- `.env`: generado y excluido por gitignore, contiene `EMDASH_ENCRYPTION_KEY`. No la escribiste tú; la escribió el scaffolder. No la commitees.
- `emdash-env.d.ts`: tipos TypeScript generados. El servidor de desarrollo lo refresca. Nunca lo edites a mano.

**Verifica:** la carpeta existe, `.env` está presente y listado en `.gitignore`, y `node_modules` está poblado. Si usas git, commitea el código y `seed/seed.json` ahora y confirma que `.env` no aparece en `git status`.

¿Ya tienes un proyecto Astro? No generes uno nuevo. La guía oficial para [añadir EmDash a un proyecto Astro existente](https://docs.emdashcms.com/existing-project/) cubre los cinco paquetes que instalas a mano (`emdash @astrojs/node @astrojs/react react react-dom`) más el mismo cableado de config que recorre este tutorial.

## Paso 2: arranca el servidor de desarrollo

```bash
cd my-emdash-site
npm run dev    # or: bun dev
```

El arranque tendrá este aspecto:

```sh
➤ npm run dev

> my-emdash-site@0.0.3 dev
> astro dev

13:12:05 [@astrojs/node] Enabling sessions with filesystem storage


  — E M D A S H —  v1.0.1

13:12:05 [vite] connected.
13:12:05 [types] Generated 1ms
13:12:05 [vite] connected.

  › Admin UI    http://127.0.0.1:4321/_emdash/admin
  › MCP server  http://127.0.0.1:4321/_emdash/api/mcp
  › Dev bypass  http://127.0.0.1:4321/_emdash/api/setup/dev-bypass?redirect=/_emdash/admin
    Skips passkey setup/auth and signs you in as a dev admin

 astro  v7.3.5 ready in 2752 ms
┃ Local    http://localhost:4321/
┃ Network  use --host to expose
13:12:07 watching for file changes...
13:12:08 [vite] [optimizer] bundling dependencies...
(node:44901) ExperimentalWarning: SQLite is an experimental feature and might change at any time
(Use `node --trace-warnings ...` to show where the warning was created)
[datetime migration] 0 noncanonical values (0 naive) using UTC
Auto-seeded default collections
13:12:10 [200] POST /_emdash/api/typegen 2207ms
13:12:10 [200] POST /_emdash/api/typegen 2ms

```

Obtienes el banner del servidor de desarrollo de Astro apuntando a `http://localhost:4321/`. Mantén este terminal abierto; el servidor de desarrollo es tu rebuild, tu generador de tipos y tu runtime a la vez.

![Terminal mostrando el servidor de desarrollo de EmDash con Astro corriendo en localhost puerto 4321](@assets/images/26/09/dev-server.svg)

Abre `http://localhost:4321/` y deberías ver la página de inicio de la plantilla Blog. Abre `http://localhost:4321/_emdash/admin/` y un sitio recién creado te redirige al asistente de configuración, que es el Paso 3.

El primer request puede parecer lento. Astro está transpilando el bundle del admin y EmDash está aplicando sus migraciones de base de datos en el primer request (el modo de migración por defecto es `runtime: "auto"`). Las cargas siguientes son rápidas.

**Verifica:**

```bash
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:4321/
# expect: 200
```

**Modo de fallo:** si el puerto 4321 está ocupado, Astro elige el siguiente puerto libre y lo imprime. Usa la URL que imprime, no la de este artículo. La URL del asistente de configuración y cualquier passkey que registres después están ligadas al origen que hayas abierto.

## Paso 3: primeros pasos con el asistente de configuración de EmDash

El asistente en `http://localhost:4321/_emdash/admin/` corre una sola vez, sobre una base de datos vacía. Tres pantallas.

**Pantalla 1: información del sitio.** Introduce un **Site Title** (y un tagline opcional). Deja **Include sample content** seleccionado. De ese checkbox salen el post Welcome y la página About; sin él el admin se ve más vacío que en las capturas de abajo.

![Pantalla del asistente de configuración de EmDash para introducir el título del sitio y el tagline](@assets/images/26/09/1-em-dash-setup.webp)

**Pantalla 2: tu cuenta.** El email es obligatorio; el nombre es opcional. El primer usuario siempre es Admin, el rol con acceso total a ajustes, plugins, esquema y otros usuarios. Los roles por debajo de Admin son Subscriber, Contributor, Author y Editor (en ese orden de capacidad). No los necesitarás hasta que alguien más edite contigo.

**Pantalla 3: registra una passkey.** Cuando aparezca el prompt de credenciales del navegador, aprueba. Luego haz clic en **Open the dashboard**.


**Verifica:** `/_emdash/admin` carga el dashboard sin formulario de login de por medio. Esa única comprobación prueba que el admin React se montó, la base de datos es alcanzable y tu passkey funciona. Si aparece el dashboard, el resto de este tutorial es papeleo.

### Registra tu primera passkey (y la trampa de localhost)

Una passkey es una credencial WebAuthn. El prompt se ve distinto según la plataforma: Touch ID o contraseña de dispositivo en macOS, Windows Hello en Windows, Face ID o huella en móvil, o una llave de seguridad física en cualquier parte. Las passkeys se sincronizan por iCloud Keychain, Google Password Manager o 1Password, así que normalmente obtienes una vía de recuperación gratis.

EmDash permite hasta 10 passkeys por usuario y se niega a eliminar la última. Registra una passkey de respaldo en los ajustes de cuenta antes de tener algo que valga la pena perder.

<Notice type="warning" title="Las passkeys están ligadas al dominio">
Una passkey registrada en `localhost:4321` nunca funcionará en `example.com`, y lo contrario también es cierto. Después de desplegar (Partes 3 y 4), te registras de nuevo en el dominio real. Planifícalo ahora para que el primer bloqueo del día del despliegue no te pille por sorpresa.
</Notice>

Conviene saberlo antes de depender de ello: el fallback de magic link solo funciona si configuras el email, y hay rate limits (las opciones de passkey están limitadas por IP por minuto). Si eres el único admin, pierdes todas las passkeys y no hay email configurado, la recuperación significa resetear la base de datos. Registra una passkey de respaldo.

## Paso 4: un recorrido por el panel de administración de EmDash

Un ancla conceptual antes de hacer clic por ahí: **EmDash edita datos, los componentes Astro son dueños del HTML.** Esto no es un page builder visual. No hay layout de arrastrar y soltar. Los editores cambian contenido, menús y widgets; tus plantillas deciden qué se renderiza y dónde.

![Dashboard de administración de EmDash tras la configuración con posts, páginas y medios en el sidebar](@assets/images/26/09/2-emdash-dashboard.webp)

El sidebar se agrupa en tres secciones. **Content** contiene Pages, Posts, Media y cualquier tipo de contenido que crees después. **Manage** cubre Comments, Menus, Redirects, Widgets, Sections, Categories, Tags y Bylines. **Admin** es el extremo del operador: Content Types, Byline Schema, Users, Plugins, Import y Settings. Dedica tu tiempo a las áreas de abajo; el resto son obvias cuando las necesites.

### Posts y páginas: borradores vs. publicados

Posts y Pages son las dos colecciones que siembra la plantilla Blog. La vista de lista muestra el estado (draft, published, scheduled, trash), y el editor es TipTap sobre Portable Text (JSON estructurado, no HTML crudo en la base de datos).

Abrir una entrada toma un bloqueo de edición. Si alguien más (u otra pestaña) la tiene abierta, tu guardado obsoleto es rechazado en vez de ganar en silencio. Ese comportamiento importa más de lo que parece cuando un agente también está editando a través del servidor MCP después.

<Notice type="info" title="Save vs Publish">
**Save** escribe un borrador privado. **Publish changes** lo hace público. Nada aparece en el sitio hasta que publicas. El control de programación registra una hora de publicación para un borrador, así que "publicar más tarde" es una acción de primera clase, no un hack con cron.
</Notice>

### Biblioteca de medios: subidas, recorte y límites

Subidas de arrastrar y soltar con estados de cola por archivo. Formatos aceptados: PNG, JPEG, GIF, WebP, AVIF, MP4, WebM, MOV, MP3, WAV, Ogg y PDF. Las imágenes tienen presets de recorte (Original, Freeform, Square, 4:3, 3:2, 16:9) más un punto focal. Carpetas, filtros por tipo y búsqueda por nombre de archivo la mantienen usable más allá de unas docenas de archivos.

![Biblioteca de medios de EmDash mostrando subidas de arrastrar y soltar y opciones de recorte de imagen](@assets/images/26/09/3-emdash-media-library.webp)

Dos comportamientos que conviene conocer desde el principio:

- **Replace image** mantiene el ID del medio, así que cada referencia a esa imagen se actualiza a la vez. Úsalo para "mismo hueco, nueva imagen" en vez de borrar y volver a subir.
- **Used in** (seguimiento de uso) está apagado hasta que lo activas una vez en Settings. Es de un solo sentido: una vez activado, no se puede desactivar.

<Notice type="warning" title="Las subidas de SVG están bloqueadas por defecto">
Los SVG están excluidos porque pueden llevar contenido activo. Tu logo en formato SVG fallará al subirlo; exporta una copia PNG o WebP. El `maxUploadSize` por defecto es 50 MB. Ambos límites son configurables, pero déjalos quietos hasta tener una razón para no hacerlo.
</Notice>

### Menús y áreas de widgets (por qué el sitio no cambia todavía)

Esta es la sección que hace tropezar a todos. Crea un menú en **Menus** (nómbralo `primary`, etiquétalo "Primary nav") y añade un enlace personalizado. Luego abre **Widget Areas** (`/_emdash/admin/widgets`), añade un área `sidebar` y suelta dentro un widget de contenido y `core:recent-posts`.

Ahora recarga el sitio público. Nada cambió.

Ese es el comportamiento correcto. EmDash guardó tu menú y tus widgets; ninguna plantilla Astro los está renderizando todavía. Las plantillas llaman a `getMenu("primary", { locale })` (devuelve `null` cuando el menú no existe) y `getWidgetArea("sidebar")` con un pequeño renderizador de widgets. Hasta que escribas ese código, los cambios del lado del admin permanecen invisibles. Los tipos de widget cubren contenido (Portable Text), menús y componentes como `core:recent-posts`, `core:categories`, `core:tags`, `core:search` y `core:archives`.

![Pantalla de áreas de widgets de EmDash con un área sidebar y widgets de contenido](@assets/images/26/09/4-emdash-widgets.webp)

Nada en las páginas Astro generadas renderiza áreas de widgets todavía, así que crear una ahora es la forma honesta de ver los datos guardarse. El lado del renderizado pertenece a tus plantillas, no a este tutorial.

### Content Types y Settings: el esquema y los controles

Dos pantallas del grupo Admin terminan el recorrido. Ojéalas ahora, cámbialas después.

**Content Types** es el editor de esquema: el mismo modelo que `seed/seed.json` escribió durante la configuración, editable en la UI. Crear un tipo pide etiquetas en singular y plural más un slug. El slug es lo que tu código pasa a `getEmDashCollection("testimonials")` y lo que usan los endpoints de la API. Los toggles merecen una lectura: **Routable** exige un slug antes de que una entrada pueda publicarse, **Edit locking** es el comportamiento de retener la entrada de la sección de Posts, y **URL pattern** acepta los tokens `{slug}`, `{id}` y de fecha, así que un permalink estilo WordPress `/{year}/{month}/{day}/{slug}` es una línea de config. Un grupo de navegación pliega tipos relacionados en una carpeta colapsable del sidebar, y el campo de icono acepta un nombre de icono Phosphor. Los tipos nuevos aparecen bajo Content en cuanto guardas.

![Pantalla de nuevo Content Type de EmDash con campos de etiqueta y slug, toggles de Routable y Edit locking, URL pattern, grupo de navegación y opciones de icono](@assets/images/26/09/6-em-dash-content-types-new.webp)

Dos trampas de esquema para anotar: añadir un campo a un tipo existente no rellena valores en entradas viejas (solo obtienen el campo nuevo cuando las editas y guardas), y cada cambio de modelo regenera `emdash-env.d.ts` en el siguiente tick del servidor de desarrollo, así que tu código de queries se mantiene tipado.

**Settings** reúne los controles operativos en una pantalla: Site (identidad, logo, enlaces sociales, campos SEO), Media (el toggle unidireccional de seguimiento de uso de la sección de medios), Security (passkeys y dominios de auto-registro), API Tokens (personal access tokens para la REST API y el servidor MCP), Email (estado del proveedor y correo de prueba, la misma dependencia de magic link del Paso 3), Backups (descarga manual más backups automáticos programados), Transfer (exportar este sitio a otra instalación de EmDash) y Language para la UI del admin.

![Pantalla de Settings de EmDash con las secciones Site, seguimiento de uso de Media, Security, API Tokens, Email, Backups, Transfer y Language](@assets/images/26/09/7-em-dash-settings.webp)

Los dos que conviene recordar el primer día: **API Tokens** es de donde sale un token con alcance de contributor cuando conectes un agente más adelante, y **Backups** es el botón que convierte el trípode `data.db` + `uploads/` + clave de cifrado del Paso 6 en un archivo descargable.

## Paso 5: publica tu primera edición (una mini demo de EmDash CMS)

Este es el momento demo: una edición en el admin apareciendo en el sitio público sin rebuild.

1. Ve a **Posts** y abre el post de ejemplo **Welcome**.
2. Cambia el título a `Hello from EmDash`.
3. Haz clic en **Save**. La entrada ahora es un borrador con tu cambio.
4. Haz clic en **Publish changes**.
5. Recarga `http://localhost:4321/`.

![Editor de posts de EmDash con los botones Save y Publish changes resaltados](@assets/images/26/09/5-em-dash-first-post.webp)

La página de inicio ahora lista "Hello from EmDash". Nada se rebuildeó, nada se redesplegó: el servidor de desarrollo nunca se reinició y `astro build` nunca corrió.


Por qué funcionó en dos segundos: la página de inicio de la plantilla Blog llama a `getEmDashCollection("posts")` en tiempo de render. Con las Live Content Collections, la query lee el contenido publicado de la base de datos en cada request, así que la siguiente carga de página ve la siguiente versión.

**Verifica:** renombra el post otra vez, guarda, publica, recarga. La ida y vuelta funciona. Ahora despublícalo y recarga; el post desaparece de la lista. Si alguna dirección falla, la página probablemente está prerenderizada. Esa es la causa número uno de "mi edición no apareció", y el Paso 7 explica el arreglo.

## Paso 6: cómo encajan los archivos de configuración

Cuatro archivos explican el proyecto entero. El modelo mental primero: **el esquema vive en la base de datos, los tipos se generan a partir de él, el contenido vive en la base de datos, y el HTML pertenece a Astro.** Tu repo guarda código y el modelo inicial, no el contenido.

![Cómo astro.config.mjs, src/live.config.ts, seed/seed.json y .env se conectan a SQLite, tipos generados y HTML renderizado por Astro](@assets/images/26/09/config-anatomy.svg)

### `astro.config.mjs`: output server, adaptador de Node y `react()`

Esta es la forma que genera la ruta Node/SQLite (también la forma que escribirías a mano en un proyecto existente):

```js
import node from "@astrojs/node";
import react from "@astrojs/react";
import { defineConfig } from "astro/config";
import emdash, { local } from "emdash/astro";
import { sqlite } from "emdash/db";

export default defineConfig({
  output: "server",
  adapter: node({ mode: "standalone" }),
  integrations: [
    react(),
    emdash({
      database: sqlite({ url: "file:./data.db" }),
      storage: local({
        directory: "./uploads",
        baseUrl: "/_emdash/api/media/file",
      }),
    }),
  ],
});
```

Línea a línea: `output: "server"` hace que las páginas se rendericen en tiempo de request, que es lo que te da publicación instantánea. `node({ mode: "standalone" })` produce un servidor Node autocontenido en `dist/server/entry.mjs`. `react()` registra React porque el panel de administración es una app React. `sqlite({ url: "file:./data.db" })` es tu base de datos entera, un archivo. `local({ directory: "./uploads", ... })` guarda los medios en disco y los sirve por la ruta de medios de EmDash; si omites `storage`, EmDash usa por defecto `./.emdash/uploads` con el mismo `baseUrl`.

<Notice type="error" title="react() es obligatorio, incluso en un sitio Astro puro">
Olvidar `react()` en `integrations` es la trampa clásica de EmDash. Instalar `@astrojs/react`, `react` y `react-dom` no basta; la integración debe estar registrada. Síntoma: `/_emdash/admin` se queda colgado en "Loading EmDash…". Arregla la config y reinicia el servidor de desarrollo.
</Notice>

Para lo que el framework subyacente está haciendo últimamente con el output server y los builds, mira [las novedades en rendimiento de build de Astro](/astro-7-faster-builds/).

### `src/live.config.ts`: el puente entre la base de datos y Astro

```ts
import { defineLiveCollection } from "astro:content";
import { emdashLoader } from "emdash/runtime";

export const collections = {
  _emdash: defineLiveCollection({ loader: emdashLoader() }),
};
```

Este es el cable entre el contenido respaldado por base de datos y la capa de contenido de Astro. `emdashLoader()` lee el modelo que vive en la base de datos y lo expone como una live collection, así que `getEmDashCollection("posts")` queda tipada y consultable. Si ya existe un `src/live.config.ts` en tu proyecto, añade la entrada `_emdash` a su objeto `collections` en vez de sobrescribir el archivo. Un `src/content.config.ts` basado en archivos sigue funcionando al lado.

El servidor de desarrollo también refresca `emdash-env.d.ts` cuando cambia el modelo, así que tu editor recoge los campos nuevos sin paso manual. No edites ese archivo; regenera, nunca parchees. (Para esquemas remotos hay una ruta aparte: `npx emdash types` escribe `.emdash/types.ts` y `.emdash/schema.json`.)

### `seed/seed.json`, `.env` y `EMDASH_ENCRYPTION_KEY`

`seed/seed.json` declara tu modelo inicial: colecciones, sus campos y contenido de ejemplo. El asistente de configuración lo aplica sobre una base de datos vacía. Nunca sobrescribe datos existentes en ejecuciones posteriores. Este archivo es lo que commiteas a git y versionas a medida que tu esquema evoluciona; el contenido en sí no está en el repo.

`.env` guarda `EMDASH_ENCRYPTION_KEY`, generada por el scaffolder. Puedes generar una tú mismo:

```bash
npx emdash secrets generate --write .env
```

`--write` se niega a sobrescribir una clave existente sin `--force`, lo cual es un buen valor por defecto. Lo que hace la clave: cifra los secrets de plugins en reposo. La clave en sí nunca se almacena en la base de datos.

<Notice type="warning" title="Haz backup de la clave de cifrado por separado">
Restaura la base de datos sin `EMDASH_ENCRYPTION_KEY` y cada ajuste de plugin cifrado queda ilegible. Guarda una copia de recuperación fuera de tus backups normales de base de datos, el mismo sitio donde guardarías una exportación de tu gestor de contraseñas. Esta es la tercera pata del trípode de backup: `data.db` + `uploads/` + la clave.
</Notice>

Variables de entorno que conviene conocer (lista completa en la [referencia de configuración](https://docs.emdashcms.com/reference/configuration/)):

| Variable | Qué hace |
|---|---|
| `EMDASH_SITE_URL` | URL pública del sitio. Obligatoria en cualquier host que no sea loopback; la configuración falla con `SITE_URL_REQUIRED` en caso contrario. Hace fallback a `SITE_URL`. |
| `EMDASH_DATABASE_URL` | Sobrescribe la ubicación de la base de datos fuera de `astro.config.mjs`. |
| `EMDASH_ALLOWED_ORIGINS` | Orígenes de navegador extra aceptados en la verificación de passkey (despliegues multi-hostname). |
| `EMDASH_PREVIEW_SECRET` | Secret para las URLs de preview de borradores. |
| `EMDASH_IP_SALT` | Salt para el hash de IP de comentaristas. |
| `EMDASH_MIGRATIONS_MODE` | `auto` (por defecto: aplica en el primer request), `check` (503 mientras haya pendientes), `manual`. |

## Plugins de EmDash: cómo funcionan y cómo añadir uno

Sí, los plugins se instalan con npm, pero a qué paquete npm recurres depende de cuál de los dos formatos de plugin de EmDash quieras decir. La distinción es todo el diseño.

Los **plugins nativos** son paquetes npm normales que corren en-proceso con acceso completo a tu sitio. Son para código en el que confías: paquetes first-party como forms, SEO, audit log e integraciones de email. Instala y registra uno en `astro.config.mjs`:

```bash
npm install @emdash-cms/plugin-forms
```

```js
// astro.config.mjs
import forms from "@emdash-cms/plugin-forms";

export default defineConfig({
  output: "server",
  adapter: node({ mode: "standalone" }),
  integrations: [
    react(),
    emdash({
      database: sqlite({ url: "file:./data.db" }),
      storage: local({
        directory: "./uploads",
        baseUrl: "/_emdash/api/media/file",
      }),
      plugins: [forms()],
    }),
  ],
});
```

`npm install`, importa, añade al array `plugins: []`, reinicia el servidor de desarrollo. Las pantallas y hooks del plugin se unen al admin en el siguiente request, y sus entradas aparecen bajo **Admin > Plugins**. Actualizar es `npm update` como cualquier otra dependencia, que es la idea: el código confiable se comporta como código confiable.

Los **plugins en sandbox** son las instalaciones del registro. Cada uno trae un manifiesto de capacidades (`content:read`, `email:send`, hostnames declarados para llamadas salientes) que el admin te hace aprobar al instalar, y cada hook y ruta corre en un proceso `workerd` aislado con límites fijos: 128 MB de memoria, 30 segundos de tiempo de pared, sin acceso de red a hosts no declarados. Los instalas desde la pantalla Plugins dentro de `/_emdash/admin`, no editando package.json.

En esta ruta Node.js, los plugins en sandbox necesitan un paquete extra, porque la capa de aislamiento es el runtime `workerd` de Cloudflare corriendo como proceso hijo en tu propia máquina:

```bash
npm install @emdash-cms/sandbox-workerd workerd
```

```js
sandboxRunner: "@emdash-cms/sandbox-workerd/sandbox",
```

Sin él el sitio corre bien pero cada instalación del registro falla con `SANDBOX_NOT_AVAILABLE`, lo que confunde a la gente porque nada parece roto hasta que lo intentas. (En Cloudflare el equivalente es el binding Worker Loader, que necesita Workers Paid; la Parte 3 lo cubre. `sandbox: false` corre el código en sandbox en-proceso, pero eso es un interruptor de depuración, no una postura.)

<Notice type="info" title="¿Necesitas plugins hoy?">
No. Este tutorial funciona de principio a fin sin ninguno, y la plantilla Blog ya cubre posts, páginas, medios, menús y widgets. Vuelve a esta sección cuando quieras un plugin de formularios, una instalación del registro o campos SEO automatizados. Es un añadido de dos minutos, no un paso de configuración.
</Notice>

## Paso 7: consulta tus primeros posts con `getEmDashCollection`

El `src/pages/index.astro` de la plantilla Blog ya hace la versión básica. Esta es la forma que vale la pena aprender, con manejo de errores y el cache hint:

```astro
---
import { getEmDashCollection } from "emdash";

const { entries: posts, error, cacheHint } = await getEmDashCollection("posts", {
  orderBy: { published_at: "desc" },
  limit: 7,
});

if (error) {
  console.error("Failed to load posts:", error);
  return new Response("Unable to load posts", { status: 500 });
}
if (Astro.cache?.enabled) Astro.cache.set(cacheHint);
---
<ul>
  {posts.map((post) => <li>{post.data.title}</li>)}
</ul>
```

Cinco cosas que enseña:

1. **Los errores vuelven como datos, no excepciones.** El objeto resultado es `{ entries, error, cacheHint, nextCursor, hasMore }`. Un array `entries` vacío significa tanto "sin coincidencias" como "la query falló", así que comprueba `error` antes de confiar en una lista vacía.
2. **Las queries públicas por defecto usan `status: "published"`**. Los borradores son invisibles para `getEmDashCollection` hasta que publicas (o le pasas a alguien una URL de preview).
3. **`orderBy` usa nombres de columna de la base de datos**: `published_at`, `created_at`, `updated_at`. Los nombres de campo camelCase que ves en `entry.data` (como `entry.data.publishedAt`) no son válidos en `orderBy`. Usa la forma snake_case en todas partes; un par de ejemplos viejos en la documentación usan camelCase ahí, y la guía de queries es la que vale.
4. **`entry.id` es el slug que se expone a la URL; `entry.data.id` es el ID de contenido estable**, que sobrevive a un cambio de slug. Enlaza al primero, guarda referencias al segundo.
5. **Los filtros `where` se combinan con AND** (objeto de pares clave/valor) con valores array que significan OR, y claves de taxonomía que matchean slugs de términos. Los cursores de paginación vuelven como `nextCursor` más un flag `hasMore`.

Para una página de post individual, cambia a `getEmDashEntry`:

```astro
---
import { getEmDashEntry } from "emdash";
import { PortableText, Image } from "emdash/ui";

const { entry: post, error, isPreview } = await getEmDashEntry("posts", Astro.params.slug!);
if (error) return new Response("Unable to load post", { status: 500 });
if (!post) return new Response(null, { status: 404 });
---
<h1>{post.data.title}</h1>
<PortableText value={post.data.content} />
<Image image={post.data.featured_image} priority />
```

`isPreview` es true en requests con token de preview, así que puedes etiquetar vistas de borradores. `PortableText` e `Image` vienen de `emdash/ui`; `getSeoMeta(post, { siteTitle, siteUrl, path })` rellena tus meta tags. Cuando la caché de Astro está activada, `Astro.cache.set(cacheHint)` etiqueta la página y los eventos de publicación la invalidan.

![Flujo de request: navegador, render de Astro, getEmDashCollection, SQLite, respuesta HTML sin rebuild al publicar](@assets/images/26/09/live-data-flow.svg)

**Verifica:** edita el título del post una vez más, guarda, publica, recarga. El cambio aparece en el siguiente request porque la página se renderiza en servidor (`output: "server"` del Paso 6). Las páginas prerenderizadas son lo contrario: congeladas en build time hasta que rebuildeas. Si tus ediciones nunca aparecen, comprueba de cuál de las dos es tu página.

## Solución de errores comunes al configurar EmDash CMS

Síntoma, causa, arreglo. Estos son los fallos con los que la gente choca en la primera hora.

| Síntoma | Causa | Arreglo |
|---|---|---|
| Admin colgado en "Loading EmDash…" | `react()` falta en `integrations` | Registra `react()` en `astro.config.mjs`; instalar los paquetes solo no basta. Reinicia el servidor de desarrollo. |
| Errores de `getEmDashCollection()` sobre una live collection | `src/live.config.ts` no tiene la entrada `_emdash` | Añade `defineLiveCollection({ loader: emdashLoader() })` al objeto `collections`. |
| Las ediciones no aparecen en la página | La página está prerenderizada, o el sitio no es `output: "server"` | Renderiza en servidor para ver las publicaciones en el siguiente request; las páginas prerenderizadas necesitan un rebuild. |
| Passkey rechazada tras el despliegue | Las passkeys están ligadas al dominio | Registra una passkey nueva en el dominio nuevo. La de `localhost` se queda en `localhost`. |
| `orderBy` rechaza campos camelCase | `orderBy` toma nombres de columna de la base de datos | Usa `published_at`, `created_at`, `updated_at`. |
| La instalación de dependencias murió a mitad del scaffold | Tropiezo de red o del registro | Los archivos del proyecto permanecen. Ejecuta el comando de reintento que imprimió el scaffolder. |
| Servidor de desarrollo en un puerto inesperado | 4321 ya en uso | Usa la URL que imprime Astro. Las passkeys y la URL del asistente van con ese origen. |
| La configuración falla con `SITE_URL_REQUIRED` | Configuración en un host no-loopback sin `EMDASH_SITE_URL` | Define `EMDASH_SITE_URL` (o `siteUrl` en la config) antes de correr la configuración fuera de localhost. |

**Rollback y borrón y cuenta nueva.** Ctrl+C al servidor de desarrollo, luego borra la carpeta del proyecto; eso es un deshacer completo, ya que `data.db`, `uploads/` y `.env` viven dentro. Dos advertencias para versiones posteriores de este proyecto: `npx emdash migrate` no tiene `down` ni `--dry-run` (comprueba el trabajo pendiente con `npx emdash migrate --check`), así que "revertir" un cambio de esquema significa restaurar archivos, no correr una migración de bajada. Para el proceso antes de cambiar por un `data.db` de backup; un servidor vivo sujetando el file handle viejo te corromperá la tarde.

Dos chequeos rápidos de salud cuando algo parezca raro:

```bash
npx emdash doctor      # local DB: connection, migrations, collections, users
npx emdash whoami      # dev-bypass auth on localhost shows your admin user
```

## Verificación: tu primer sitio EmDash funciona

Recorre esta lista antes de pasar al despliegue. Cada ítem corresponde a algo que construyó este tutorial.

<ListCheck>
<ul>
<li>`http://localhost:4321/` renderiza y lista el post renombrado</li>
<li>`/_emdash/admin` carga el dashboard (prueba `react()` + base de datos + auth)</li>
<li>La ida y vuelta de borradores funciona: editar → Save → Publish changes → visible; Unpublish → desaparece</li>
<li>Una subida de medio aparece dentro de una página Portable Text, y borrar funciona</li>
<li>`npx emdash doctor` pasa contra la base de datos local</li>
<li>Opcional: `npx emdash whoami` muestra tu usuario admin</li>
</ul>
</ListCheck>

## FAQ: preguntas comunes sobre cómo empezar con EmDash

<Accordion label="¿Necesito una cuenta de Cloudflare para seguir este tutorial?" group="faq" expanded="true">
No. Todo este tutorial corre en Node.js con SQLite (`data.db`) y un directorio local `uploads/`. Cloudflare entra solo cuando eliges el destino de despliegue Cloudflare o despliegas a Workers, que es la Parte 3 de la serie. Nada aquí requiere tarjeta, prueba ni cuenta.
</Accordion>

<Accordion label="¿Dónde vive mi contenido? ¿Puedo seguir usando git?" group="faq">
El contenido vive en la base de datos SQLite (`data.db`), no en tu repo. Es deliberado: el JSON de Portable Text en una base de datos es lo que permite a los editores trabajar sin tocar git. Lo que sí commiteas es código más `seed/seed.json`, el modelo inicial versionado. Haz backup de tres cosas juntas: `data.db`, `uploads/` y `EMDASH_ENCRYPTION_KEY` de `.env`.
</Accordion>

<Accordion label="¿Qué es una passkey y qué pasa si mi navegador no la soporta?" group="faq">
Una passkey es una credencial WebAuthn, normalmente respaldada por Touch ID, Windows Hello, Face ID o una llave hardware, y sincronizada por iCloud Keychain, Google Password Manager o 1Password. Todo navegador de escritorio actual las maneja en localhost. El fallback de magic link existe pero necesita email configurado. Consejo práctico: registra una passkey de respaldo en los ajustes de cuenta el día que crees tu admin.
</Accordion>

<Accordion label="¿Por qué necesito React en un sitio que no es React?" group="faq">
El panel de administración en `/_emdash/admin` es una aplicación React. La integración `react()` de Astro es lo que la monta. Olvídala y el admin se cuelga en "Loading EmDash…". Tus plantillas de sitio pueden seguir siendo Astro puro; React se incluye para la UI de edición.
</Accordion>

<Accordion label="Save vs Publish: ¿cuál hace público el contenido?" group="faq">
**Publish changes**. Save escribe un borrador privado que solo los editores (y las URLs de preview) pueden ver. Como las páginas se renderizan en servidor y están respaldadas por Live Content Collections, una edición publicada aparece en el siguiente request sin rebuild ni reinicio del servidor de desarrollo. Los posts programados registran una hora de publicación y cambian automáticamente.
</Accordion>

<Accordion label="¿Por qué mi menú o widget no cambió el sitio?" group="faq">
Porque el admin guarda datos y tus plantillas Astro los renderizan. Un menú llamado `primary` solo aparece cuando una plantilla llama a `getMenu("primary", { locale })`; los widgets necesitan `getWidgetArea("sidebar")` más un renderizador. EmDash no es un page builder visual, así que los datos de layout del lado del admin permanecen invisibles hasta que tu código los pida.
</Accordion>

<Accordion label="¿Qué cifra EMDASH_ENCRYPTION_KEY y qué pasa si la pierdo?" group="faq">
Cifra los secrets de plugins en reposo. La clave la provee el operador y nunca se almacena en la base de datos, así que restaurar `data.db` sin la clave deja los ajustes de plugins ilegibles. Guarda una copia de recuperación fuera de tus backups de base de datos. Y recuerda: está en gitignore a propósito. Commitearla anula todo el sentido de cifrar secrets.
</Accordion>

## Siguientes pasos: despliega tu sitio EmDash

Ya tienes un sitio EmDash local corriendo y un modelo mental funcional de sus partes. Local está bien para construir. Todavía no es un sitio web.

Tres rutas hacia adelante, en el orden en que las cubre esta serie:

1. **Parte 3: Cloudflare Workers + D1 + R2.** La ruta todo-Cloudflare, la misma plataforma sobre la que corre el Cloudflare Blog. El tier gratis cubre un sitio básico (las plantillas se entregan sin el binding Worker Loader); los plugins en sandbox necesitan Workers Paid, unos 5 $/mes a septiembre de 2026. Verifica el precio antes de comprometerte.
2. **Parte 4: tu propio VPS con Node.js y Docker.** La documentación oficial de despliegue en Node.js te da un Dockerfile multi-stage `node:22-alpine` y un archivo compose con un volumen nombrado para `/app/data` (ese volumen guarda tu base de datos y tus uploads en un solo sitio). Un VPS de 1-2 GB como un [Hetzner Cloud VPS](https://go.bitdoze.com/hetzner) corre a escala de blog cómodamente por unos 4-5 €/mes. Si quieres el stack alrededor mientras tanto, [desplegar un sitio Astro en un VPS con CloudPanel](/deploy-astro-on-vps/) o [alojar apps Node.js en un VPS con CloudPanel y PM2](/install-cloudpanel-host-nodejs/) cubren las dos mitades de ese montaje.
3. **Alternativa zero-ops.** [Desplegar apps en tu propio servidor con Coolify](/coolify-install-heroku-alternative/) te da despliegues con git push y un proxy HTTPS sin escribir units de systemd a mano.

Endurecimiento de producción, adelantado ahora y cubierto en serio en las Partes 3 y 4: define `EMDASH_SITE_URL` en cualquier host que no sea localhost, corre el backup de tres patas (`data.db` + `uploads/` + la clave de cifrado), mantén el límite de subida de 50 MB y la prohibición de SVG hasta tener una razón para cambiarlos, y define `trustedProxyHeaders` detrás de un reverse proxy para que los rate limits de auth por IP se comporten bien.

Comandos CLI que vale la pena tener abiertos en un segundo terminal:

```bash
npx emdash doctor                    # local database health check
npx emdash whoami                    # who the dev bypass says you are
npx emdash content list posts        # list content from the terminal
npx emdash seed --validate           # CI-safe seed check
npx emdash secrets generate --write .env

npm run build
node --env-file=.env ./dist/server/entry.mjs   # standalone does NOT auto-load .env
```

Esa última línea es el cliffhanger de la Parte 4: el servidor standalone espera que le pases `--env-file`. Olvídalo y la app arranca sin `EMDASH_ENCRYPTION_KEY`.

<Button text="Empieza con nuestra reseña de EmDash CMS" link="/es/emdash-cms-resena/" variant="solid" color="blue" size="md" icon="arrow-right" />