EmDash ↗︎ is an open-source, full-stack content management system (CMS) for Astro. An EmDash site runs on Workers, stores content in D1, and stores media in R2. It includes an admin panel, a plugin system, and a Model Context Protocol (MCP) server for editing content from AI tools.
The source code is available on GitHub ↗︎.
You need Node.js 22.23.4 or later and a Cloudflare account.
-
Create a project with
create-emdash↗︎:npm create emdash@latest -- my-emdash-siteyarn create emdash my-emdash-sitepnpm create emdash@latest my-emdash-site -
When prompted, choose the following options:
- Where will you deploy? Cloudflare Workers
- Which template? Starter, Blog, Marketing, or Portfolio
- Install dependencies? Yes
- Enable sandboxed plugins? No. You can turn them on later. For more information, refer to Sandboxed plugins.
The scaffolder writes a generated
EMDASH_ENCRYPTION_KEYto a.envfile. Do not commit this file. -
Go to the project directory:
cd my-emdash-site
To create a project without prompts, pass the template and platform as flags:
npm create emdash@latest -- my-emdash-site --template cloudflare:starter --yesyarn create emdash my-emdash-site --template cloudflare:starter --yespnpm create emdash@latest my-emdash-site --template cloudflare:starter --yesAn EmDash site uses the following Cloudflare products:
| Product | Binding | Purpose | Configured in |
|---|---|---|---|
| D1 | DB |
Stores content, users, and settings | wrangler.jsonc |
| R2 | MEDIA |
Stores uploaded media | wrangler.jsonc |
| Cron Triggers | — | Runs scheduled publishing, plugin tasks, and backups | wrangler.jsonc |
| Workers KV | SESSION |
Stores Astro sessions | Added by @astrojs/cloudflare at build |
| Images | IMAGES |
Resizes and optimizes media | Added by @astrojs/cloudflare at build |
| Workers Static Assets | ASSETS |
Serves the built front end and admin panel files | Added by @astrojs/cloudflare at build |
| Dynamic Workers | LOADER |
Runs sandboxed plugins in isolation (optional) | wrangler.jsonc |
The template configures the D1, R2, and Cron Trigger bindings in wrangler.jsonc:
{
"$schema": "./node_modules/wrangler/config-schema.json",
"name": "my-emdash-site",
"main": "./src/worker.ts",
// Set this to today's date
"compatibility_date": "2026-11-06",
"d1_databases": [
{
"binding": "DB",
"database_name": "my-emdash-site"
}
],
"r2_buckets": [
{
"binding": "MEDIA",
"bucket_name": "my-emdash-media"
}
],
"triggers": {
"crons": [
"* * * * *"
]
}
}name = "my-emdash-site"
main = "./src/worker.ts"
# Set this to today's date
compatibility_date = "2026-11-06"
[[d1_databases]]
binding = "DB"
database_name = "my-emdash-site"
[[r2_buckets]]
binding = "MEDIA"
bucket_name = "my-emdash-media"
[triggers]
crons = ["* * * * *"]The template pins an earlier compatibility date and sets the nodejs_compat flag. Keep the flag unless you update the compatibility date to 2026-08-04 or later, which turns on Node.js compatibility by default.
EmDash connects to these bindings by name in astro.config.mjs. The binding names in both files must match:
import cloudflare from "@astrojs/cloudflare";
import react from "@astrojs/react";
import { d1, r2 } from "@emdash-cms/cloudflare";
import { defineConfig } from "astro/config";
import emdash from "emdash/astro";
export default defineConfig({
output: "server",
adapter: cloudflare(),
integrations: [
react(),
emdash({
database: d1({ binding: "DB", session: "auto", coalesce: true }),
storage: r2({ binding: "MEDIA" }),
}),
],
});The Worker entry point in src/worker.ts exports a scheduled handler. The Cron Trigger calls this handler to publish scheduled content. Keep both the Cron Trigger and the handler, or scheduled entries do not go live.
-
Build the site:
npm run buildyarn run buildpnpm run build -
Preview the site:
npx wrangler devyarn wrangler devpnpm wrangler devOpen the local URL that Wrangler prints. To start the setup wizard, go to
/_emdash/admin/. -
Deploy the site:
npm run deployyarn run deploypnpm run deployOn the first deploy, Wrangler creates the D1 database, R2 bucket, and KV namespace that the site uses.
-
Open the
workers.devURL that Wrangler prints, then go to/_emdash/admin/to complete setup.
To use encrypted plugin settings in production, upload the encryption key from .env as a secret:
npx wrangler secret put EMDASH_ENCRYPTION_KEYyarn wrangler secret put EMDASH_ENCRYPTION_KEYpnpm wrangler secret put EMDASH_ENCRYPTION_KEYEmDash can run plugins in isolated sandboxes with Dynamic Workers. Sandboxed plugins cannot access environment variables or the file system.
To turn on sandboxed plugins:
-
In
wrangler.jsonc, uncomment theworker_loadersbinding:{ "$schema": "./node_modules/wrangler/config-schema.json", "worker_loaders": [ { "binding": "LOADER" } ] }[[worker_loaders]] binding = "LOADER" -
In
astro.config.mjs, importsandboxand pass it to EmDash assandboxRunner:astro.config.mjsjs import { d1, r2, sandbox } from "@emdash-cms/cloudflare"; emdash({ database: d1({ binding: "DB", session: "auto", coalesce: true }), storage: r2({ binding: "MEDIA" }), sandboxRunner: sandbox(), }); -
Build and deploy the site again.
Each EmDash template for Cloudflare includes an AGENTS.md file, agent skills, and an MCP configuration for the EmDash documentation ↗︎. AI coding tools that read these files can work with EmDash without extra setup.
Each deployed site also includes an MCP server ↗︎. Connect an MCP client to draft, edit, and schedule content.
- Deploy to Cloudflare ↗︎ in the EmDash documentation covers caching, custom domains, email, Cloudflare Access, and preview deployments.
- Migrate from WordPress ↗︎ imports posts, pages, and media from a WordPress export.
- Astro on Workers covers the Astro adapter, bindings, and sessions.