article
How blog.mikepenz.dev is built: 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.
Astro renders the page on demand and fetches content from the Storyblok Content Delivery API.
The Workers Cache API keeps rendered pages, tagged per article, series and page.
A publish in Storyblok calls the Worker, which purges the tags of the changed page.
The same build with STORYBLOK_PREVIEW=1 reads drafts. A signed token from the Visual Editor is the only way in.
All flows as text:
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.I publish a story. Storyblok calls
/api/webhook?key=…, the Worker purges the cache tags of that page, and the next request renders fresh.I open a draft in the Visual Editor. It loads
preview.blog.mikepenz.devwith a signed_storyblok_tktoken, and the preview Worker renders the draft with live updates.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:
claude mcp add --transport http Storyblok https://mcp.storyblok.com/mcpThe 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:
No file input. A story is typed into the call, so the model retypes every payload. Moving the 20 stories produced three typing slips in two of them, among them a URL that came out as
api.github.com/repos/…. Compare every push with the source and repeat until the diff is empty.Drafts only. Pushing with
publish: falsedoes not unpublish a live story. It showspublished: truewith unpublished changes, so the live version stays up while I review the draft.
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
Cloudflare Access versus the Visual Editor. The preview host serves drafts, so I put a Worker-level Access policy in front of it. Direct visits got a login, but the editor showed a blank "refused to connect" page: the Access login sends
x-frame-options: DENYand cannot render in Storyblok's iframe. I turned Access off, and the preview Worker now checks the signed_storyblok_tktoken that the editor already adds.Dates a day off. The first version of the site fell back to
first_published_atwhen a story had nodate. That was wrong for 7 of 16 articles. The order is nowdate,created_at,first_published_at.Stale HTML in browsers. The zone's Browser Cache TTL of four hours overrode the Worker's
max-age=0. The Worker now sendsmust-revalidate, and cached responses come back withmax-age=0, must-revalidate. I did not pin down why the rewrite stopped.A secrets file nearly committed. A
git addpicked up.dev.vars.(a typo with a trailing dot). It was never committed, and a scan of all git blobs found none of the values. Secrets now live only in 1Password and as Worker secrets, and staged files are scanned for them before every commit.
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
Storyblok's draft CDN adds
_editablecomments to every blok. Strip them before diffing a draft against your source.@storyblok/richtext5.5.1 turns on markdown-it's typographer in its markdown converter. Straight quotes became curly ones until the conversion went through HTML with the typographer switched off.wrangler devkeeps the Workers Cache API on disk. After a code change, delete the cache state or you keep seeing old HTML.The
workers.devaddress of the production Worker serves the whole site publicly.Storyblok's webhook docs describe no retry on failure, so a failed purge is not repeated.
What you need
A Storyblok space. The MCP signs in with OAuth, so it needs no token. The site itself reads content with two access tokens from Space > Settings > Access Tokens: a public one for the production Worker and a preview one for drafts.
A Cloudflare account with your domain on it. Deploys need an API token, and the cache purge needs a second, narrower one plus the zone id.
Claude Code, and optionally a container runtime. I use Apple's
containerso no Node runs on my host.A place for secrets. I keep them in a 1Password Environment mounted as a
.envfile outside the project. A shell profile or your CI's secret store works too.
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.