Shipping a blog on EmDash in an afternoon

The deploy was gnarlier than the marketing suggests, and simpler than WordPress would ever be. Here's what went right, what caught me out, and the five footguns to know before you try it.

Cloudflare shipped EmDash this week — a TypeScript CMS on Astro and Workers, positioned as the spiritual successor to WordPress. I had mondello.dev sitting idle and took an afternoon to point it at EmDash end-to-end.

Short version: the deploy was gnarlier than the marketing suggests, and also simpler than WordPress would ever be. This is what went right, what caught me out, and the five footguns you should know about before you try it.

The stack

EmDash is an Astro integration. You get @astrojs/cloudflare on Workers, D1 for the content database, R2 for media, KV for sessions, a first-party MCP server, a WordPress importer, an x402 paywall package, and Dynamic Worker Loaders sandboxing plugins in v8 isolates. It's MIT licensed and v0.1.0 beta as of this week.

My target shape was the minimum credible personal blog: a custom domain, first-party admin, RSS, search, tags, and agent-friendly discoverability files. No custom theme. Default template. Ship first.

The template

The official blog-cloudflare template lives at emdash-cms/templates/blog-cloudflare. I cloned it directly instead of running npm create emdash@latest because the scaffolder uses @clack/prompts and I didn't want an interactive step in my pipeline.

git clone --depth 1 https://github.com/emdash-cms/templates.git /tmp/emdash-templates
cp -r /tmp/emdash-templates/blog-cloudflare ~/mondello-dev
cd ~/mondello-dev && pnpm install

pnpm install pulls emdash 0.1.1, @astrojs/cloudflare 13.1.8, astro 6.1.5, and a project-local wrangler 4.81.1. The project-local wrangler matters because the template pins ^4.79.0 — if you only have an older global wrangler, let pnpm win.

The five things that worked

D1 and R2 provision with one command each.

pnpm wrangler d1 create mondello-dev
pnpm wrangler r2 bucket create mondello-dev-media

Paste the D1 database id wrangler prints into wrangler.jsonc and you're done. No schema to apply separately — EmDash reads seed/seed.json at runtime and initializes D1 on the first request. Zero explicit migration step, zero template-side SQL, zero bootstrap script to babysit.

The custom-domain binding is a three-line config. Add a routes block with custom_domain: true and wrangler handles DNS record creation, CNAME-flattening for the apex, and SSL provisioning automatically:

"routes": [
  { "pattern": "mondello.dev", "custom_domain": true },
  { "pattern": "www.mondello.dev", "custom_domain": true }
]

If the zone is already in the same Cloudflare account, the whole handshake takes ~30 seconds from wrangler deploy to a valid cert.

The plugin architecture is the real differentiator. definePlugin() gives you capability-scoped access (read:content, email:send, etc.), a KV-style storage scoped to the plugin, admin pages and widgets, lifecycle hooks (content:afterSave, media:beforeUpload), and API routes with Zod input validation. Sandboxed plugins run in their own v8 isolates via Dynamic Worker Loaders and physically cannot touch anything they didn't declare.

This is the thing WordPress should have been and couldn't be. You can ship a plugin ecosystem without handing each plugin the whole database.

Passkey-first auth. The setup wizard registers a WebAuthn credential on first visit, and iCloud Keychain syncs it across your devices. I created the credential on my phone once and it worked on my Mac immediately after. No password to rotate.

getEmDashCollection() is the whole content API. From any Astro page, server route, or layout:

---
import { getEmDashCollection, getSiteSettings } from "emdash";
const { entries: posts } = await getEmDashCollection("posts", {
  orderBy: { published_at: "desc" },
  limit: 20,
});
const settings = await getSiteSettings();
---

That's it. No GraphQL, no REST client, no separate fetch layer, no build step for types. The collection data is typed from the schema and hydrated with references (bylines, tags) in a single call.

The five things that caught me out

pnpm deploy isn't the template's deploy script. pnpm has a built-in deploy command that packages a workspace for shipping and it shadows any scripts.deploy in package.json. The template defines "deploy": "wrangler deploy", but pnpm deploy runs the pnpm builtin and errors with ERR_PNPM_NOTHING_TO_DEPLOY. The fix is pnpm run deploy — the explicit form invokes the script. Took me one confused minute and a grep through the pnpm docs to figure out.

Dynamic Worker Loaders require Workers Paid. The template ships with worker_loaders in wrangler.jsonc and a sandboxRunner: sandbox() line in astro.config.mjs to power sandboxed plugins. On a free Cloudflare account the deploy fails with:

A request to the Cloudflare API failed.
In order to use Dynamic Workers, you must switch to a paid plan. [code: 10195]

The fix is to comment out the worker_loaders block, remove the sandboxRunner line, and also remove the marketplace: option (which implicitly depends on sandboxRunner and errors the build if you don't). Forms plugin still runs in-process and still works. You lose the marketplace and sandboxed-plugin features until you upgrade, which is a $5/month flip.

@astrojs/cloudflare auto-injects a SESSION KV binding. The Astro Cloudflare adapter transparently provisions a KV namespace for session storage at deploy time. On the first deploy wrangler auto-creates it. If a previous deploy left the KV namespace behind and you then declare it manually in wrangler.jsonc to avoid collisions, wrangler double-provisions on the next deploy and errors with code: 10014 — a namespace with this account ID and title already exists. The cleanest fix is to let the adapter own the binding and not declare it manually. If you already have an orphan namespace, wrangler kv namespace delete it and let the next deploy recreate it from scratch.

Custom-domain attach refuses if there are existing DNS records at the target hostname. If mondello.dev already has A/AAAA records pointing at a legacy origin, wrangler fails the route binding with:

Hostname 'mondello.dev' already has externally managed DNS records.
Either delete them, try a different hostname, or use the option
'override_existing_dns_record' to override. [code: 100117]

The route-level override_existing_dns_record: true flag doesn't work in wrangler 4.81 — I tried. The reliable fix is to manually delete the conflicting records in the Cloudflare dashboard and redeploy. MX records are untouched; only A/AAAA at the apex and www conflict.

The template hardcodes "My Blog" in four places. src/pages/rss.xml.ts, src/layouts/Base.astro, src/pages/index.astro, and src/pages/posts/[slug].astro all ship with a literal siteTitle = "My Blog" constant instead of reading from getSiteSettings(). If you run the setup wizard and enter your real site title, the admin shows the new value but the RSS feed, the nav, the footer, and the SEO meta all keep saying "My Blog" until you fix the template. I've got a PR in flight to upstream the fix to emdash-cms/templates.

The agent-era layer

Once the blog was up, I added three files that every site should ship in 2026:

  • /llms.txt — a discovery manifest per llmstxt.org that lists every post and page as a link line, plus optional refs to RSS, sitemap, and the full-content variant.
  • /llms-full.txt — all posts inlined as plain text so an agent can ingest the whole site in one HTTP fetch, capped at 100 entries.
  • /robots.txt with explicit Allow rules for sixteen known AI crawlers: GPTBot, ClaudeBot, Google-Extended, PerplexityBot, Applebot-Extended, CCBot, meta-externalagent, Bytespider, MistralAI-User, cohere-ai, and a few others.

These are wired through three small plugins I published under plugins/ in the blog repo:

  • emdash-plugin-llms-txt — pure functional generators for llms.txt and llms-full.txt, zero framework coupling.
  • emdash-plugin-agent-seorobots.txt generator with a versioned bot catalog and an Organization JSON-LD builder.
  • emdash-plugin-posthog — PostHog snippet builder with admin-path auto-exclusion and DNT respect.

All three are MIT, structured for npm extraction, and live in the integrate-your-mind/mondello-dev repo for now. They'll move to standalone public repos next week.

One caveat if you're on Cloudflare: the zone-level "AI Scrapers and Crawlers" feature prepends its own block list to /robots.txt regardless of whatever your Worker returns. If your goal is agent discoverability, turn that off in the dashboard under Security → Bots. It took me longer than it should have to notice that my carefully-written allow list was being served below CF's block list and therefore losing to first-match semantics.

The verdict

If you're a TypeScript person who wants sandboxed plugins, a real plugin ecosystem model, agent-era features in the box, and a production path that doesn't involve PHP, EmDash is the thing. The gap between "clone the template" and "live on a custom domain with SSL" is measured in minutes once you know the footguns. The rough edges I hit are all solvable in one-line config changes or upstream PRs, and the architecture underneath is honest — structured content, capability-scoped plugins, portable database and storage layers.

If you need the 60,000-plugin WordPress ecosystem today, this is v0.1.0 and it doesn't exist yet. Check back in six months.

For me, the bet is that the serverless-TypeScript-with-sandboxed-plugins model is where publishing is going and that being early on the platform is worth more than any specific feature. The whole stack of this blog — Worker, D1, R2, KV, custom domain, agent SEO, analytics-ready — costs $0 a month on the free tier and $5 a month to unlock the full plugin system. That's the kind of math that makes "just ship it" the obviously correct call.

The source is at github.com/integrate-your-mind/mondello-dev . The three plugins extract cleanly as standalone packages when you need them. If you want the one-click version, the EmDash team publishes a "Deploy to Cloudflare" button in their README.

Go ship something.