Hello.

article

How blog.mikepenz.dev is built: Storyblok, Astro and Cloudflare Workers

2026-11-07 · Storyblok, Astro and Cloudflare Workers

TL;DR This blog is Storyblok for content, Astro rendering on demand inside a Cloudflare Worker, and an edge cache that purges when I publish. One build runs as two Workers, public and preview. Two traps to know: Cloudflare Access cannot sit in front of a Visual Editor preview, and a zone's Browser Cache TTL can override the Worker's cache headers.

The blog used to be a Nuxt 2 static export from June 2023, with the content already in Storyblok. The stack was outdated, the static export had limits I wanted to get past, and I wanted to see how far Claude Code and the Storyblok MCP could take a real migration. Claude Code writes most of the code and pushes content to Storyblok as drafts. Deploys, DNS, access rules, the webhook and the decision to publish stay with me.

The setup in one picture

From publish to page

I write and publish here. The Visual Editor shows the preview site in an iframe.

1 / 5

All flows as text:

  1. A visitor requests blog.mikepenz.dev/a/<slug>/. The Worker looks in the edge cache, and on a miss renders the page from Storyblok and stores it.

  2. I publish a story. Storyblok calls /api/webhook?key=…, the Worker purges the cache tags of that page, and the next request renders fresh.

  3. I open a draft in the Visual Editor. It loads preview.blog.mikepenz.dev with a signed _storyblok_tk token, and the preview Worker renders the draft with live updates.

  4. Claude Code reads and writes Storyblok through the MCP. Everything it writes is a draft.

Storyblok and the MCP

The content stays in Storyblok. An Article, Series or Page has one richtext content field, and richtext may contain a short list of interactive bloks, such as Figure, Callout, CodeTabs, Steps, Terminal, EdgeProbe and RequestFlow.

Claude Code talks to Storyblok through its MCP server. Setup is one command plus OAuth in the browser, so no token is involved:

bash
claude mcp add --transport http Storyblok https://mcp.storyblok.com/mcp

The tools follow a loop: search for an operation, describe it, then execute with the tool describe names. Ask for fields, because a component list carries every schema and gets large. Two limits shape the workflow:

Part 3 has the details of the migration itself.

Astro on a Worker

Astro renders every page on demand (output: 'server', @astrojs/cloudflare). Cloudflare was an easy call: the domain already lives there, and Workers are cheap. The Free plan includes 100,000 requests a day and the Paid plan starts at $5 a month, according to Cloudflare's pricing page.

The adapter writes a wrangler.json without env blocks, so one config cannot describe production and preview. A script deploys the same build twice instead, with a different --name and --var STORYBLOK_PREVIEW:0|1. Production holds only the public Storyblok token and cannot read drafts. Part 2 walks through it.

There is no Node on my host. Every JavaScript command runs in a container through a small wrapper, with secrets mounted from a 1Password Environment outside the project.

The cache

Without a cache, every page view costs a render and Storyblok calls. The Worker stores pages with the Workers Cache API under a key that contains a per-build id, so every deploy starts empty. Pages carry Cache-Tag values, and a Storyblok webhook purges by tag, never by URL: Cloudflare's docs say not every purge endpoint reaches entries written by the Cache API, and a URL purge cannot match a custom key.

Storyblok webhook secrets need a paid plan, so the handler also accepts a ?key= in the webhook URL. Part 4 covers the trade-offs.

What broke

What changed

The first answer to the preview problem was a better Access policy. No variant works: policies cannot match a query parameter, service tokens need headers an iframe cannot add, and a login done in a normal tab becomes a third-party cookie in the iframe. The second answer was to drop Access for the preview and trust the token Storyblok signs.

For the cache, the first TTL was 300 seconds. It went to 3600 only after a publish had evicted a cached page. A cache you cannot purge reliably should expire quickly.

After

Measured

Cache hits

240 of 240 requests on six URLs

CPU per hit

about 1.2 to 1.6 ms

CPU per render

6 to 21 ms (sampled on HEAD and 404 requests), 100 to 262 ms for the first render in a cold isolate

Storyblok calls on a hit

0

Purge after publish

in one test, the page was a miss 15 to 30 seconds later

Bundle

3022 KiB, 513 KiB gzipped

The cache buys latency and Storyblok headroom. On cost, a simple model (Paid plan assumed, 300-second TTL) gives $5 a month at 10,000 to 1 million views with or without the cache, so it saves nothing on the invoice. Measurements come from one location reaching one data center (VIE).

Good to know

What you need

That is eight values in total: six Worker secrets and two that only the deploy shell sees. Part 2 has the table with how to create each one and which Worker holds it. Part 4 sets up the webhook that uses three of them.

Try it yourself

Paste this into Claude Code in the repo of the blog you want to move. It does the migration in checkpoints: a plan first, then the build, the preview gate, the content push and the cache. It leaves deploys, domains and publishing to you.

Migrate a Storyblok blog to Astro on Cloudflare Workers

Migrate my blog to Astro (server rendering) on Cloudflare Workers, with Storyblok as the CMS. Work in the current repo and the Storyblok MCP (space <space-id>, region <region>).

Rules that always apply:
- Never print, log or commit a secret or token. Read secrets from the environment only, and report booleans or lengths.
- Secrets: tell me which ones the project needs, by name, with the kind of token and where I create it (Storyblok public and preview access tokens, a Cloudflare deploy token, a purge-only token and zone id, a webhook key). I store the values in my environment and you read them from there. If one is missing, ask me; never ask me to paste a value into this chat.
- Push to Storyblok as drafts only (publish: false). Do not publish, unpublish, delete or change the schema.
- Do not deploy, attach domains or touch DNS. Stop and tell me what to run; I do those myself.
- Claim only what you ran or read. Say what you verified, with the command, and what you did not.

Steps. Finish each one and show the result before the next:
1. Inspect: list the existing routes and URL forms (trailing slash, redirects), and the Storyblok content types and fields. Use search, then describe, then execute, and pass `fields` to keep responses small. Write nothing yet. Give me a short plan and wait for my go.
2. Build: Astro with output server and @astrojs/cloudflare, content from the Storyblok Content Delivery API. One build, deployed twice with a STORYBLOK_PREVIEW variable: production reads published content, preview reads drafts.
3. Preview gate: protect the preview host with the signed _storyblok_tk token the Visual Editor adds (SHA-1 of space_id:preview_token:timestamp, under an hour old). Fail closed with a 403, and test it. Do not use Cloudflare Access: its login page cannot render inside the editor iframe.
4. Content: back up every original story as JSON, convert to richtext offline, and keep the old URLs. Push each story as a draft, then read it back from the CDN with ?version=draft and diff it against your source, ignoring _uid, attrs.id and _editable. The MCP retypes every payload, so repeat until there are 0 differences.
5. Cache: store rendered pages with the Workers Cache API under a key that includes a per-build id, tag each page with Cache-Tag, and purge by tag (never by URL) from a Storyblok webhook. Do not cache a render where a Storyblok call failed. Send must-revalidate so a zone Browser Cache TTL cannot keep stale HTML in browsers.
6. Finish: run the build and any tests, then list what changed, what you verified and what is still open. Tell me the exact commands for deploying, the domains, the webhook and publishing.

The prompt contains no secrets. It asks for the names of the secrets first, and you create the values yourself as listed under What you need above and in part 2.

Built with AI; I reviewed and published everything.