WEBVTT - Shipping a blog on EmDash in an afternoon

1
00:00:00.000 --> 00:00:02.825
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.

2
00:00:02.825 --> 00:00:04.278
Short version: the deploy was gnarlier than the marketing suggests, and also simpler than WordPress would ever be.

3
00:00:04.278 --> 00:00:05.973
This is what went right, what caught me out, and the five footguns you should know about before you try it.

4
00:00:05.973 --> 00:00:06.134
The stack

5
00:00:06.134 --> 00:00:06.538
EmDash is an Astro integration.

6
00:00:06.538 --> 00:00:09.444
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.

7
00:00:09.444 --> 00:00:09.847
It's MIT licensed and v0.

8
00:00:09.847 --> 00:00:09.928
1.

9
00:00:09.928 --> 00:00:10.412
0 beta as of this week.

10
00:00:10.412 --> 00:00:12.672
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.

11
00:00:12.672 --> 00:00:12.834
The template

12
00:00:12.834 --> 00:00:15.416
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.

13
00:00:15.416 --> 00:00:18.241
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.

14
00:00:18.241 --> 00:00:18.645
The five things that worked

15
00:00:18.645 --> 00:00:19.291
D1 and R2 provision with one command each.

16
00:00:19.291 --> 00:00:20.017
Paste the D1 database id wrangler prints into wrangler.

17
00:00:20.017 --> 00:00:20.340
jsonc and you're done.

18
00:00:20.340 --> 00:00:21.066
No schema to apply separately — EmDash reads seed/seed.

19
00:00:21.066 --> 00:00:21.874
json at runtime and initializes D1 on the first request.

20
00:00:21.874 --> 00:00:22.842
Zero explicit migration step, zero template-side SQL, zero bootstrap script to babysit.

21
00:00:22.842 --> 00:00:25.102
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:

22
00:00:25.102 --> 00:00:26.959
If the zone is already in the same Cloudflare account, the whole handshake takes ~30 seconds from wrangler deploy to a valid cert.

23
00:00:26.959 --> 00:00:27.524
The plugin architecture is the real differentiator.

24
00:00:27.524 --> 00:00:28.169
definePlugin() gives you capability-scoped access (read:content, email:send, etc.)

25
00:00:28.169 --> 00:00:30.026
, 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.

26
00:00:30.026 --> 00:00:31.640
Sandboxed plugins run in their own v8 isolates via Dynamic Worker Loaders and physically cannot touch anything they didn't declare.

27
00:00:31.640 --> 00:00:33.577
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.

28
00:00:33.577 --> 00:00:33.739
Passkey-first auth.

29
00:00:33.739 --> 00:00:35.191
The setup wizard registers a WebAuthn credential on first visit, and iCloud Keychain syncs it across your devices.

30
00:00:35.191 --> 00:00:36.483
I created the credential on my phone once and it worked on my Mac immediately after.

31
00:00:36.483 --> 00:00:36.806
No password to rotate.

32
00:00:36.806 --> 00:00:37.936
getEmDashCollection() is the whole content API. From any Astro page, server route, or layout:

33
00:00:37.936 --> 00:00:40.680
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.

34
00:00:40.680 --> 00:00:41.245
The five things that caught me out

35
00:00:41.245 --> 00:00:41.810
pnpm deploy isn't the template's deploy script.

36
00:00:41.810 --> 00:00:43.182
pnpm has a built-in deploy command that packages a workspace for shipping and it shadows any scripts.

37
00:00:43.182 --> 00:00:43.424
deploy in package.

38
00:00:43.424 --> 00:00:43.505
json.

39
00:00:43.505 --> 00:00:44.877
The template defines "deploy": "wrangler deploy", but pnpm deploy runs the pnpm builtin and errors with ERR_PNPM_NOTHING_TO_DEPLOY.

40
00:00:44.877 --> 00:00:45.926
The fix is pnpm run deploy — the explicit form invokes the script.

41
00:00:45.926 --> 00:00:47.137
Took me one confused minute and a grep through the pnpm docs to figure out.

42
00:00:47.137 --> 00:00:49.801
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:

43
00:00:49.801 --> 00:00:52.303
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).

44
00:00:52.303 --> 00:00:52.949
Forms plugin still runs in-process and still works.

45
00:00:52.949 --> 00:00:54.159
You lose the marketplace and sandboxed-plugin features until you upgrade, which is a $5/month flip.

46
00:00:54.159 --> 00:00:54.644
@astrojs/cloudflare auto-injects a SESSION KV binding.

47
00:00:54.644 --> 00:00:55.854
The Astro Cloudflare adapter transparently provisions a KV namespace for session storage at deploy time.

48
00:00:55.854 --> 00:00:56.419
On the first deploy wrangler auto-creates it.

49
00:00:56.419 --> 00:00:57.791
If a previous deploy left the KV namespace behind and you then declare it manually in wrangler.

50
00:00:57.791 --> 00:00:59.890
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.

51
00:00:59.890 --> 00:01:01.181
The cleanest fix is to let the adapter own the binding and not declare it manually.

52
00:01:01.181 --> 00:01:02.876
If you already have an orphan namespace, wrangler kv namespace delete it and let the next deploy recreate it from scratch.

53
00:01:02.876 --> 00:01:05.298
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:

54
00:01:05.298 --> 00:01:06.105
The route-level override_existing_dns_record: true flag doesn't work in wrangler 4.

55
00:01:06.105 --> 00:01:06.428
81 — I tried.

56
00:01:06.428 --> 00:01:07.719
The reliable fix is to manually delete the conflicting records in the Cloudflare dashboard and redeploy.

57
00:01:07.719 --> 00:01:08.688
MX records are untouched; only A/AAAA at the apex and www conflict.

58
00:01:08.688 --> 00:01:09.334
The template hardcodes "My Blog" in four places.

59
00:01:09.334 --> 00:01:09.414
src/pages/rss.

60
00:01:09.414 --> 00:01:09.495
xml.

61
00:01:09.495 --> 00:01:09.656
ts, src/layouts/Base.

62
00:01:09.656 --> 00:01:09.818
astro, src/pages/index.

63
00:01:09.818 --> 00:01:10.060
astro, and src/pages/posts/[slug].

64
00:01:10.060 --> 00:01:11.351
astro all ship with a literal siteTitle = "My Blog" constant instead of reading from getSiteSettings().

65
00:01:11.351 --> 00:01:14.580
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.

66
00:01:14.580 --> 00:01:15.549
I've got a PR in flight to upstream the fix to emdash-cms/templates.

67
00:01:15.549 --> 00:01:15.791
The agent-era layer

68
00:01:15.791 --> 00:01:17.082
Once the blog was up, I added three files that every site should ship in 2026:

69
00:01:17.082 --> 00:01:19.261
/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.

70
00:01:19.261 --> 00:01:21.199
/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.

71
00:01:21.199 --> 00:01:23.136
/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.

72
00:01:23.136 --> 00:01:24.346
These are wired through three small plugins I published under plugins/ in the blog repo:

73
00:01:24.346 --> 00:01:25.315
emdash-plugin-llms-txt — pure functional generators for llms.txt and llms-full.txt, zero framework coupling.

74
00:01:25.315 --> 00:01:26.445
emdash-plugin-agent-seo — robots.txt generator with a versioned bot catalog and an Organization JSON-LD builder.

75
00:01:26.445 --> 00:01:27.333
emdash-plugin-posthog — PostHog snippet builder with admin-path auto-exclusion and DNT respect.

76
00:01:27.333 --> 00:01:29.270
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.

77
00:01:29.270 --> 00:01:30.884
One caveat if you're on Cloudflare: the zone-level "AI Scrapers and Crawlers" feature prepends its own block list to /robots.

78
00:01:30.884 --> 00:01:31.449
txt regardless of whatever your Worker returns.

79
00:01:31.449 --> 00:01:32.741
If your goal is agent discoverability, turn that off in the dashboard under Security → Bots.

80
00:01:32.741 --> 00:01:35.001
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.

81
00:01:35.001 --> 00:01:35.162
The verdict

82
00:01:35.162 --> 00:01:37.664
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.

83
00:01:37.664 --> 00:01:39.521
The gap between "clone the template" and "live on a custom domain with SSL" is measured in minutes once you know the footguns.

84
00:01:39.521 --> 00:01:42.023
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.

85
00:01:42.023 --> 00:01:43.718
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.

86
00:01:43.718 --> 00:01:45.978
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.

87
00:01:45.978 --> 00:01:48.803
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.

88
00:01:48.803 --> 00:01:49.933
That's the kind of math that makes "just ship it" the obviously correct call.

89
00:01:49.933 --> 00:01:52.758
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.

90
00:01:52.758 --> 00:01:53.000
Go ship something.
