Migrating from Next.js to Astro on Cloudflare Workers
I moved williammarch.xyz from Next.js 15 to Astro 7. Here's what broke, what got better, and the adapter quirks nobody writes about.
This site used to run on Next.js 15 deployed via @opennextjs/cloudflare. It worked, but it felt like overkill for a personal site with a blog and a contact form. When I decided to add a proper blog, I took the opportunity to rethink the stack.
This post covers the migration, the real gotchas, and what I’d do differently.
Why Switch At All
Next.js is excellent for apps. For a blog with a contact form, you’re carrying a lot of weight: the runtime is heavier, the Cloudflare adapter (@opennextjs/cloudflare) is a compatibility shim that emulates Node.js on Workers, and static pages still go through a request/response cycle you don’t really need.
Astro’s model is the opposite. Pages ship zero JavaScript by default. If a component needs interactivity you opt in — client:load, client:idle, client:visible — and only that component’s JS lands in the browser. Blog posts are pre-rendered at build time to plain HTML. The Worker only runs for the homepage and API routes.
Build time went from ~30 seconds to under 2 seconds. The Worker bundle went from ~2MB to ~400KB.
The Content Layer
The biggest quality-of-life improvement. In Next.js I was using gray-matter to parse frontmatter and building my own post lookup functions. In Astro 7 you define a collection:
// src/content.config.ts — note: NOT src/content/config.ts
import { defineCollection, z } from 'astro:content';
import { glob } from 'astro/loaders';
const posts = defineCollection({
loader: glob({ pattern: '**/*.md', base: './content/posts' }),
schema: z.object({
title: z.string(),
date: z.string(),
description: z.string(),
tags: z.array(z.string()).default([]),
}),
});
export const collections = { posts };
The schema validation alone is worth it — misconfigured frontmatter becomes a build error rather than a runtime surprise.
One gotcha: in Astro 7 the config file moved to src/content.config.ts. If you put it at src/content/config.ts (the old path), you get a LegacyContentConfigError and nothing works. The error message does tell you this, but it’s easy to miss.
Cloudflare Adapter Quirks
The @astrojs/cloudflare adapter reads your wrangler.jsonc at build time to configure the dev environment. This creates a few traps.
The ASSETS binding
If you have pages_build_output_dir in wrangler.jsonc, the adapter enters Pages mode. Pages reserves the ASSETS binding name for static file serving. If you have prerender = true on any page, the adapter creates a mini Worker to handle that route — and it uses binding: "ASSETS". Cloudflare then rejects the deployment:
The name 'ASSETS' is reserved in Pages projects.
The fix is prerenderEnvironment: 'node' in astro.config.mjs:
adapter: cloudflare({
prerenderEnvironment: 'node',
}),
This tells Astro to pre-render using Node.js at build time instead of the Cloudflare workerd runtime. Blog posts are still static HTML — they’re just rendered by Node.js during astro build rather than by a mini Worker. No conflict with the ASSETS binding.
Environment variables
The Astro.locals.runtime.env API was removed in adapter v14. Every tutorial and Stack Overflow answer from 2024 will tell you to use it. The runtime throws:
Astro.locals.runtime.env has been removed in Astro v6.
Use 'import { env } from "cloudflare:workers"' instead.
Switch every access to:
import { env } from 'cloudflare:workers';
// In an API route:
const secret = env.TURNSTILE_SECRET_KEY;
const gitea = env.GITEA_URL;
For TypeScript types, declare the module in src/env.d.ts:
declare module "cloudflare:workers" {
const env: {
TURNSTILE_SECRET_KEY: string;
GITEA_URL: string;
// ...
};
export { env };
}
The deploy script
The adapter generates dist/server/wrangler.json with the Worker entry point and asset directory already configured. Your route patterns from wrangler.jsonc are not merged into this file — the routes array is empty. Deploy with:
astro build && node scripts/post-build.mjs && wrangler deploy --config dist/server/wrangler.json
Where post-build.mjs injects the routes:
import { readFileSync, writeFileSync } from 'fs';
const cfg = JSON.parse(readFileSync('dist/server/wrangler.json', 'utf-8'));
cfg.routes = [
{ pattern: 'williammarch.xyz/*', zone_name: 'williammarch.xyz' },
];
cfg.workers_dev = false;
writeFileSync('dist/server/wrangler.json', JSON.stringify(cfg, null, 2));
Run wrangler from the project root with --config, not from inside dist/server/. If you cd dist/server && wrangler deploy, wrangler finds both the generated wrangler.json and the .wrangler/deploy/config.json that astro build creates in the project root and errors on the path mismatch.
React Islands
Components that need client-side interactivity (the nav menu, contact form, GitHub project grid) stay as React. They just lose the 'use client' directive — that’s a Next.js concept, not needed in Astro — and get a client:load directive at the call site:
<Nav client:load />
<Contact siteKey={siteKey} client:load />
One thing to watch: you can’t read process.env or call env from inside a React component. Those run in the browser. Pass any server-side values as props from the .astro page that wraps them:
---
import { env } from 'cloudflare:workers';
const siteKey = env.NEXT_PUBLIC_TURNSTILE_SITE_KEY ?? '';
---
<Contact siteKey={siteKey} client:load />
Fonts
The geist npm package (Vercel’s distribution) pulls in Next.js font optimization infrastructure you don’t want outside Next.js. Replace it with the fontsource variable fonts:
npm install @fontsource-variable/geist @fontsource-variable/geist-mono
---
import '@fontsource-variable/geist';
import '@fontsource-variable/geist-mono';
---
The CSS variable names change: --font-geist-sans becomes 'Geist Variable', --font-geist-mono becomes 'Geist Mono Variable'. The fonts bundle into dist/client/_astro/ as woff2 files served with a year-long cache header.
What I’d Do Differently
Skip the pages_build_output_dir approach entirely from the start. Deploy as a Worker from day one using the adapter-generated dist/server/wrangler.json. It’s the path of least resistance once you understand the output structure.
And read the adapter changelog before starting. The v14 breaking changes (runtime.env removal, prerender changes) are documented — I just didn’t check before diving in and spent time chasing runtime 500s that should have been build-time errors.
Result
The site is faster to build, lighter on the wire, and easier to add posts to. Drop a markdown file in content/posts/, run npm run deploy, done.