# Moving my blog from Vercel to Cloudflare

> Why this blog moved from Next.js on Vercel to a static Astro site on a Cloudflare Worker, the tools behind it, and what the move did for performance

- Canonical: https://dennisokeeffe.com/blog/2026-11-09-moving-my-blog-from-vercel-to-cloudflare
- Published: 2026-11-09
- Updated: 2026-11-09
- Tags: architecture, cloudflare, performance

This blog has run on [Next.js](https://nextjs.org/) and [Vercel](https://vercel.com/) for years. As of today, it is a static [Astro](https://astro.build/) site served by a [Cloudflare Worker](https://developers.cloudflare.com/workers/), with the infrastructure (DNS included) declared in code.

This blog post covers why I moved, what the old site looked like, the tools I used for the new one, the optimisations I made along the way, and some before and after numbers.

## Vercel paused my site

The move had been on my list for a long time. Most of my larger applications already live on AWS and Cloudflare, and the blog was the odd one out on a third platform. What finally forced my hand was Vercel pausing the site.

Eight hours into my 30-day billing cycle, the blog had used all 1,000,000 of the edge requests included in Vercel's free Hobby plan. Vercel paused the deployment, so anyone visiting got a "This deployment is temporarily paused" page instead of the blog, and new deployments were blocked for the rest of the month.

Most of that traffic was referred by a single domain, `www.aocr.org`. To be fair to Vercel, its firewall would have helped. [IP blocking and custom rules are free on every plan](https://vercel.com/docs/vercel-firewall/vercel-waf/usage-and-pricing), and traffic they block does not count towards your usage. I simply had no rules in place, and eight hours is not long to notice. The features that would have caught the next storm on their own, [rate limiting](https://vercel.com/docs/vercel-firewall/vercel-waf/rate-limiting) and managed rulesets, are billed by usage on Pro, and Hobby gets a single rate limiting rule per project.

I could have set those rules up and stayed. But with most of my other work already on AWS and Cloudflare, this was a good opportunity to make the move I had been putting off, to a setup built around my own requirements.


  To be clear, this is not a complaint about Vercel's limits. A million
  requests is a generous free tier. What I wanted was a setup where junk
  traffic costs nothing by design, rather than one where I have to block it
  in time.


So the requirements for the replacement were:

1. Reading the blog should cost nothing, however much traffic arrives.
2. Junk traffic should be stopped before it costs anything at all.
3. Every existing URL, image and feed entry must keep working.
4. Everything should be reviewable and repeatable in code, rather than clicked together in dashboards.

## Where we started

The old site was a Next.js 16 application with around 350 posts written in MDX. A few other moving parts sat around it:

1. Images were already served from `cdn.dennisokeeffe.com`, an S3 bucket behind CloudFront.
2. Newsletter sign-ups went to Mailchimp, with an older list sitting in a DynamoDB table.
3. Analytics came from [Simple Analytics](https://www.simpleanalytics.com/) and the full [PostHog](https://posthog.com/) JavaScript library.
4. DNS for `dennisokeeffe.com` lived in a Route 53 hosted zone, with the blog itself answering on `blog.dennisokeeffe.com`.

Next.js did its job, but for a site that is almost entirely static text, every page was shipping 400 KB to 700 KB of JavaScript, and the blog index made over 400 requests on a first visit thanks to prefetching.

## What we moved to

The new blog is a static [Astro](https://astro.build/) build, served as static assets by a single Cloudflare Worker. The Worker only runs code for one route, the newsletter sign-up. Every other request is a static file that Cloudflare serves without running the Worker at all.

```mermaid
flowchart LR
    Reader[Reader] --> Edge[Cloudflare edge]
    Edge -- "www and blog subdomains" --> Redirect["Redirect rule: 301 to dennisokeeffe.com"]
    Edge -- "dennisokeeffe.com" --> WAF["WAF: blocked referrers and rate limit"]
    subgraph Worker["dok-blog Worker on dennisokeeffe.com"]
        Static["Static files and _redirects"]
        Api["Sign-up handler"]
    end
    WAF -- "every other path" --> Static
    WAF -- "/api/subscribe" --> Api
    Api --> Turnstile[Turnstile]
    Api --> D1[(D1)]
    Reader --> CDN[cdn.dennisokeeffe.com]
```

The redirects and the WAF rules are separate pieces. Redirect rules send `www` and `blog` to the main domain, while the WAF only blocks traffic. Path redirects, such as old `/assets` image URLs pointing at the CDN, live in a `_redirects` file that ships with the static files.

This shape covers the first two requirements:

1. [Requests for static assets are free and unmetered](https://developers.cloudflare.com/workers/static-assets/billing-and-limitations/) on Workers, so a crawler storm costs nothing and takes nothing offline. Only `/api/*` counts towards the free plan's 100,000 Worker requests a day.
2. Cloudflare's [WAF custom rules](https://developers.cloudflare.com/waf/custom-rules/) block known bad referrers (hello `aocr.org`), and a [rate limiting rule](https://developers.cloudflare.com/waf/rate-limiting-rules/) blocks any IP that makes more than 120 requests in 10 seconds. Both run before a file is served and are available on the free plan.

The main tools behind the new site:

| Area | Old site | New site |
| --- | --- | --- |
| Framework | Next.js 16 | [Astro](https://astro.build/) 7, fully static |
| Hosting | Vercel (Hobby) | [Cloudflare Workers](https://developers.cloudflare.com/workers/) static assets |
| DNS | Route 53 | Cloudflare, declared in code |
| Infrastructure | Vercel dashboard | [Alchemy](https://alchemy.run/) |
| Newsletter | Mailchimp and DynamoDB | [Effect](https://effect.website/) Worker, [D1](https://developers.cloudflare.com/d1/) and [Turnstile](https://developers.cloudflare.com/turnstile/) |
| Analytics | Simple Analytics and PostHog | Simple Analytics and [posthog-js-lite](https://github.com/PostHog/posthog-js-lite) |

### Infrastructure with Alchemy

Everything Cloudflare runs is declared in one TypeScript file using [Alchemy](https://alchemy.run/): the D1 database and its migrations, the Turnstile widget, the site build, the Worker with its domains and redirects, the WAF rules, and the DNS zone with every record copied across from Route 53.

Alchemy has a `plan` command that shows what a deploy would change before it changes anything, which is a habit I have come to rely on with Pulumi on other projects. The same stack also has a `preview` stage, which deployed the new site to `next.dennisokeeffe.com` so I could compare it against the old one before switching over.

### The newsletter Worker

The only code path the Worker runs is `POST /api/subscribe`. It is written with [Effect](https://effect.website/), with each step named so that it shows up in the request's log line:

1. Check the request comes from the blog's own origin.
2. Rate limit to five attempts per IP per minute, using the [Workers rate limiting binding](https://developers.cloudflare.com/workers/runtime-apis/bindings/rate-limit/).
3. Decode the body against a schema, with a honeypot field that quietly "succeeds" for bots.
4. Verify the Turnstile token.
5. Upsert the address into D1.

Each request writes a single JSON log line (a "wide event") with the outcome, the reason and the steps it reached, so a failed sign-up can be explained from one log entry.

### Keeping old URLs working

With 350 posts and years of inbound links, breaking URLs was the biggest risk. A few things guard against it:

1. The old site's full URL list and feed entry ids were frozen into a fixture, and a test checks every one of them still resolves or redirects to the right place.
2. `www.dennisokeeffe.com` and `blog.dennisokeeffe.com` answer with a 301 to the same path and query on `dennisokeeffe.com`, using a Cloudflare redirect rule that runs before the Worker.
3. Feed entries keep the ids they had on the old site, so feed readers do not show old posts as new.
4. During the migration, a set of parity scripts compared the text of every page against the old build.

## Optimisations

Moving to a static site removes most of the JavaScript by itself, but there were a few deliberate optimisations on top.

### Little to no JavaScript

Most pages ship about 3 KB of JavaScript. The pieces that need more only load when they are needed:

1. Search is [Pagefind](https://pagefind.app/), and its index only loads the first time you focus a search box.
2. Diagrams (like the one above) are rendered to SVG at build time.
3. The few interactive posts use [Preact](https://preactjs.com/) islands that hydrate when they scroll into view.
4. Code highlighting is done at build time with [Shiki](https://shiki.style/), with the light and dark themes in a single pass.

### Fonts

The blog is set in Space Mono, as before. This [post from Shu Ding](https://x.com/shuding/status/2115855028737200211) on making web fonts load faster without changing how anything looks inspired the approach here.

Each weight is split into two files. A Latin core file is preloaded, and a Latin Extended file sits behind `unicode-range`, so it only downloads on a page that uses one of its characters. Font files are self-hosted and cached for a week, and everything in `/_astro` carries a content hash and is cached for a year.


  The font files are Google Fonts' own subsets (via Fontsource) rather than
  subsets cut to the characters this blog actually uses. Each core file is about
  17 KB against the old site's 10 KB, which is why first paint is around 0.15s
  slower on a couple of pages in the lab results below. Cutting them down
  further is next on the list.


### Analytics

Simple Analytics came across as is, served from my own subdomain, and only loads on `dennisokeeffe.com` itself, so local builds and previews stay out of the numbers.

The full PostHog library weighed in at around 100 KB compressed, which is more than the rest of a page put together. I swapped it for [posthog-js-lite](https://github.com/PostHog/posthog-js-lite), which is about 23 KB and only sends pageviews. Both scripts wait until the browser is idle before loading, so they never get in the way of reading.

## Results

I measured both sites as production builds on the same machine (`next build && next start` for the old site, `wrangler dev` for the new one), using [Lighthouse](https://developer.chrome.com/docs/lighthouse) with mobile throttling. Each page was run three times, and the tables use the median. Images and analytics were excluded on both sides so the comparison is of the sites themselves.

### Lighthouse (mobile, lab)

| Page | Score (old to new) | Total Blocking Time (old to new) | First Contentful Paint (old to new) |
| --- | --- | --- | --- |
| Home | 93 to 100 | 314 ms to 26 ms | 0.79s to 0.77s |
| Blog index | 93 to 100 | 325 ms to 61 ms | 0.95s to 0.92s |
| Long post | 84 to 100 | 643 ms to 38 ms | 0.78s to 0.92s |
| Post with diagrams | 90 to 100 | 428 ms to 26 ms | 0.78s to 0.77s |
| Tags | 96 to 100 | 219 ms to 26 ms | 0.63s to 0.77s |

The biggest change is Total Blocking Time, which is the time the main thread is too busy running JavaScript to respond to a reader. It dropped by 80% to 95% on every page.

### First visit (without images)

| Page | Old site | New site |
| --- | --- | --- |
| Home | 671 KB, 95 requests | 76 KB, 10 requests |
| Blog index | 2,131 KB, 422 requests | 101 KB, 10 requests |
| Long post | 688 KB, 88 requests | 88 KB, 11 requests |
| Post with diagrams | 881 KB, 85 requests | 129 KB, 17 requests |
| Tags | 646 KB, 192 requests | 74 KB, 10 requests |

The new site's numbers include posthog-js-lite. A first visit now costs between a sixth and a twentieth of what it used to.

### PageSpeed Insights

I could not run [PageSpeed Insights](https://pagespeed.web.dev/) against the old live site, since Vercel had paused it. The new site on `next.dennisokeeffe.com` scored 100 across performance, accessibility, best practices and SEO on desktop, with a 0.2s first contentful paint and no blocking time.

![PageSpeed Insights desktop results for the new site](https://cdn.dennisokeeffe.com/assets/2026-11-09-moving-my-blog-from-vercel-to-cloudflare/pagespeed-desktop.png)

## Moving DNS to Cloudflare

A Worker can only answer on a custom domain whose zone is on Cloudflare, so DNS had to move too. The domain registration stays with AWS, and only the nameservers changed.

I exported the Route 53 hosted zone and turned every record into code: mail (Zoho, Amazon SES, Resend and SendGrid), domain verifications, certificate validations and the other sites I run on subdomains. None of them are proxied, so each answers exactly as it did on Route 53. After a deploy to create the zone, the switch itself was one AWS CLI command to point the domain at Cloudflare's nameservers.

A few things tripped me up along the way, which I have noted in the repository's deploy docs:

1. A stale `CLOUDFLARE_API_TOKEN` left in my shell took precedence over every login I tried, and failed every plan with a `Forbidden: Authentication error`.
2. I had added and then deleted the zone in the dashboard after Alchemy had recorded it. Alchemy kept looking up a zone that no longer existed until I cleared its state for that stage.
3. When a zone is added in the dashboard, Cloudflare copies in records of its own. One of them was an A record for `cdn`, which clashed with the CNAME to CloudFront and had to be deleted by hand.


  Resolvers can keep the old nameservers cached for up to two days, so keep the
  old hosted zone around until they have all caught up.


## What is next

1. Deploys from CI on merge, and a preview URL for each pull request.
2. Cutting the fonts down to the characters the blog actually uses.
3. Bringing the old Mailchimp and DynamoDB subscribers across into D1.
4. Actually sending a newsletter again.

## Links and further reading

- [Shu Ding on optimising web fonts](https://x.com/shuding/status/2115855028737200211)
- [Vercel limits](https://vercel.com/docs/limits)
- [Astro](https://astro.build/)
- [Cloudflare Workers](https://developers.cloudflare.com/workers/)
- [Workers static assets billing and limitations](https://developers.cloudflare.com/workers/static-assets/billing-and-limitations/)
- [Cloudflare WAF custom rules](https://developers.cloudflare.com/waf/custom-rules/)
- [Cloudflare rate limiting rules](https://developers.cloudflare.com/waf/rate-limiting-rules/)
- [Workers rate limiting binding](https://developers.cloudflare.com/workers/runtime-apis/bindings/rate-limit/)
- [Cloudflare D1](https://developers.cloudflare.com/d1/)
- [Cloudflare Turnstile](https://developers.cloudflare.com/turnstile/)
- [Alchemy](https://alchemy.run/)
- [Effect](https://effect.website/)
- [Pagefind](https://pagefind.app/)
- [Shiki](https://shiki.style/)
- [Preact](https://preactjs.com/)
- [Storybook](https://storybook.js.org/)
- [posthog-js-lite](https://github.com/PostHog/posthog-js-lite)
- [Simple Analytics](https://www.simpleanalytics.com/)
- [Fontsource](https://fontsource.org/)
- [Lighthouse](https://developer.chrome.com/docs/lighthouse)
- [PageSpeed Insights](https://pagespeed.web.dev/)
