🔄 Update April 2026: Added Pitfall 10 — R2 media upload, CSRF header requirements, and the correct upload path via EmDash admin. Also: R2 public URL configured (pub-d1d7f5f57ea747fcb4c0d5bae0b1d227.r2.dev).
🔄 Continuously updated. This article is regularly updated with new insights and pitfalls. Last updated: April 2026.

Language: 🇩🇪 Deutsche Version | 🇬🇧 English

This article is a complete, honest account – including every error, debugging session, and API discovery we only found through real trial and error. Nothing is glossed over.

The Starting Point

April 2026. An AI assistant (Claude), a human with a domain idea, and a goal: a modern, fast, AI-native website for an automation magazine called Automated Web – as cheap and low-maintenance as possible.

We chose EmDash, Cloudflare's new open-source CMS launched on April 1, 2026 as the "spiritual successor to WordPress". The promise: serverless, secure, AI-native, deployed on Cloudflare Workers without your own server.

What followed: 75 minutes of productive collaboration, five real errors, and two unexpected API quirks – ending with a live website for around $6 per month. This article documents everything.

The Real Cost Breakdown

ComponentCost/MonthNotes
Domain automatedweb.net~$1$11.86/year at Cloudflare
Workers Paid Plan$5Required – Free Plan not enough
R2 Storage (up to 10 GB)$0Free, but credit card required
D1 Database$0Included
SSL Certificate$0Automatic
Global CDN$0Cloudflare network worldwide
Total~$6WordPress hosting: $15–50/month

Step 1: Buy the Domain

Register directly at Cloudflare Registrar – domain and Workers hosting in the same dashboard. No DNS config, no nameserver transfers, no propagation delays. The .com was only available with a hyphen (automated-web.com) – a branding no-go. automatedweb.net is clean, tech-affine, and works equally well in German and English.

Step 2: Install EmDash

node --version
# v22.22.0

npm create emdash@latest

Setup wizard choices: project name → Cloudflare Workers (D1 + R2)Starter template → npm. About 2 minutes total.

⚠️ Pitfall #1: You must explicitly type "y"

npm asks: "Need to install create-emdash@0.1.0 – Ok to proceed? (y)". Pressing Enter without typing y → npm error canceled. Run again, type y explicitly.

Step 3: Test Locally

cd automatedweb
npm run dev

Admin panel at http://localhost:4321/_emdash/admin. Set title, create passkey (no password!), enable sample content for immediate layout preview.

Step 4: Prepare Cloudflare

4a: Activate R2

R2 must be activated once in the Cloudflare dashboard even though the first 10 GB are free. Credit card required for verification.

⚠️ Pitfall #2: worker_loader requires the Paid Plan – no workaround

EmDash uses a worker_loader binding for plugin sandboxing that is only available in the Workers Paid Plan ($5/month). With the free plan:

X [ERROR] binding LOADER of type worker_loader is invalid [code: 10021]

No workaround exists. The Paid Plan is mandatory for any live EmDash installation.

Step 5: Deploy

npm run deploy

First deploy opens Cloudflare OAuth in the browser. After confirming, everything runs automatically.

⚠️ Pitfall #3: KV Namespace already exists [code: 10014]

If a previous failed attempt already created resources:

X [ERROR] a namespace with this account ID and title already exists [code: 10014]

Fix: Cloudflare Dashboard → Workers & Pages → KV → delete namespace my-emdash-site-session → re-run npm run deploy.

⚠️ Pitfall #4: wrangler.jsonc is ignored during provisioning

Manually adding the KV namespace ID to wrangler.jsonc has no effect. npm run deploy uses --experimental-provision internally which overrides the config. Only fix: delete the namespace and let Wrangler recreate it.

🎉 All resources provisioned, continuing with deployment...
Worker Startup Time: 128 ms
Uploaded my-emdash-site (24.67 sec)
Deployed my-emdash-site triggers (6.41 sec)
https://my-emdash-site.deinname.workers.dev

Step 6: Connect Custom Domain

Cloudflare Dashboard → Workers & Pages → project → Settings → Custom Domains → Add. Since domain and hosting are both at Cloudflare: active immediately, zero waiting.

⚠️ Pitfall #5: Production database is empty – run setup twice

Local and production are completely separate databases. When you open your live domain for the first time, the setup form appears again. Run setup twice: locally + on the live site.

Step 7: AI Integration via MCP

EmDash's standout feature: every installation ships with a built-in MCP server. AI assistants like Claude can directly create, edit, and publish content – without touching the admin backend.

  1. Open Admin on the live URL (not localhost!)
  2. Settings → API Tokens → create new token
  3. Edit Claude Desktop config: %APPDATA%\Claude\claude_desktop_config.json
  4. Add EmDash as MCP server with URL and token
  5. Restart Claude Desktop

⚠️ Pitfall #6: API token must be created on the live URL

A token created on localhost:4321 only works locally – different databases. On the live site you get:

{"error":{"code":"INVALID_TOKEN","message":"Invalid or expired token"}}

Fix: Always create the token on your live domain, never on localhost.

⚠️ Pitfall #7: EmDash API structure differs from REST conventions

Point 1 – Correct API path: /_emdash/api/content/posts (not /admin/posts). Wrong path → 404.

Point 2 – data wrapper required for POST:

// Falsch – 400 VALIDATION_ERROR:
{ title: "Post", content: "..." }

// Richtig – 201 Created:
{ data: { title: "Post", content: "..." } }

Without the wrapper: 400 VALIDATION_ERROR: Invalid input – no hint that the wrapper is missing.

⚠️ Pitfall #8: PATCH returns 404 – use PUT

// PATCH → immer 404
// PUT  → 200 OK (korrektes Update)
// POST /:id/publish → 200 OK (veroeffentlichen)
// POST /content/posts → 201 (erstellen mit data-Wrapper)

Additionally, the slug is auto-generated from the title on creation and cannot be changed via API after publishing. Long title = long ugly slug. Use a short, SEO-friendly title when creating, then update the full title via PUT later.

// Slug wird auto aus Titel generiert – NICHT ueberschreibbar
// Langer Titel = langer Slug
// Kurzerer Titel beim Erstellen verwenden!
await fetch("/_emdash/api/content/posts", {
  method: "POST",
  body: JSON.stringify({ data: {
    title: "Kurzer SEO-Titel", // wird zum Slug
    content: "...",
    status: "draft"          // dann /publish aufrufen
  }})
});

⚠️ Pitfall #9: npm run deploy does NOT rebuild – run npm run build first

This is a subtle but important quirk of the EmDash workflow. If you change the theme or Astro templates and then run npm run deploy directly, the old build gets deployed – your changes never go live.

This is because npm run deploy internally just calls wrangler deploy, which uploads the existing dist/ folder without recompiling.

Correct workflow for template changes:

# 1. Build first (~45-50 seconds)
npm run build

# 2. Then deploy
npm run deploy

# NOT: npm run deploy alone – it deploys the old version!

We learned this the hard way: a template fix (HTML renderer for article content) was deployed, but the article still showed the PortableText error – because the build still contained the old code.

Our Recommendations

Register domain at Cloudflare – eliminates all DNS configuration
Activate Workers Paid Plan immediately – deploy fails without it
Activate R2 before first deploy – credit card needed, 10 GB free
KV error 10014: delete namespace, re-deploy
Create API token on live URL only – never on localhost
POST with data wrapper – { data: { title, content, ... } }
Use PUT for updates, not PATCH – PATCH returns 404
POST /:id/publish to publish – separate endpoint
Set slug via short title at creation – cannot be changed after publishing
Run setup twice – local and production are separate databases
For theme changes: npm run build BEFORE npm run deploy – otherwise old code gets deployed
HTML content in theme: use Fragment set:html – PortableText only renders JSON, not HTML strings

FAQ

Do I need programming skills?

Basic terminal skills are enough. Copy and run commands – no need to write code.

Why does the MCP server return 404 even though the token is correct?

Two likely causes: (1) wrong API path – correct is /_emdash/api/content/posts; (2) missing data wrapper on POST/PUT.

Can I migrate from WordPress?

EmDash supports WordPress WXR export import. Custom post types need manual work.

What if my site goes viral?

Workers Paid includes 10M requests/month, then $0.30/million. Automatic scaling, no crashes.

Is EmDash production-ready?

Launched April 2026 – great for new projects. For migrating critical business sites, wait 6–12 more months for the ecosystem to mature.

Does EmDash support multilingual sites?

No built-in i18n yet. Our solution: slugs with language suffix (articlename-de / articlename-en) and language tags.

Conclusion

75 minutes. $6 per month. A modern, AI-native, globally hosted website with built-in MCP server, passkey authentication, and automatic scaling – no own server, no maintenance overhead.

EmDash is young, but EmDash + Cloudflare Workers is the most compelling option for new projects we know of right now.

This article was published by Claude directly via MCP – after we debugged all eight pitfalls together and documented every one of them.

👤

About the Author

Hendrik Muth

SEO specialist and marketing expert at AnalyticaA in Munich, Germany. In his spare time he experiments with AI tools, automation workflows – and builds websites like this one in under 75 minutes.

More about Hendrik →

Found another pitfall? Let us know – we update this list continuously.

Pitfall 10: Media Upload – R2, CSRF, and the Right Path

This one took the longest. Uploading images should be simple — but with EmDash on Cloudflare Workers, it's not out of the box.

The Problem: NOT_SUPPORTED

The POST /media/upload-url endpoint returns NOT_SUPPORTED:

{ "error": { "code": "NOT_SUPPORTED", "message": "Storage does not support signed upload URLs. Use direct upload." } }

Root cause: The native R2 binding (@emdash-cms/cloudflare) doesn't support pre-signed URLs — this is an EmDash design decision. Uploads must go through the Worker.

The Solution: Three Steps

Step 1 – Enable R2 Public Access:

npx wrangler r2 bucket dev-url enable my-emdash-media
# → https://pub-XXXXX.r2.dev

Step 2 – Add publicUrl to astro.config.mjs:

storage: r2({ binding: "MEDIA", publicUrl: "https://pub-XXXXX.r2.dev" })

Step 3 – Rebuild and Deploy:

npm run build && npm run deploy

The CSRF Bypass for API Uploads

External POST requests are blocked by Cloudflare with a 403. The correct header, per EmDash source code (src/api/csrf.ts):

headers: {
  "Authorization": "Bearer TOKEN",
  "X-EmDash-Request": "1",   // ← The magic header
  ...form.getHeaders()
}

This header tells EmDash the request is legitimate. Browsers can't set it cross-origin — so it's safe as CSRF protection.

This pitfall cost us several hours — the error message was misleading and we had to navigate through Cloudflare WAF, EmDash CSRF middleware, and R2 binding logic.