This site runs on Next.js 15, deployed to Cloudflare Workers through the OpenNext adapter. It ran on Vercel when I first wrote this, and moving it taught me which of these patterns were Next.js patterns and which were platform conveniences I had mistaken for framework features. Rather more of them were the second kind than I expected, so this is rewritten around where the site actually is.
React Server Components: the default that changes everything
Next.js 15 defaults to Server Components, and this is not just a rendering strategy. It is a shift in where the weight sits.
Server Components render on the server and send HTML. No JavaScript bundle for the component, and crucially none for the libraries it imports. A date formatter, a markdown renderer, a syntax highlighter: all of that stays server-side and never reaches the browser. No hydration cost either.
- Blog posts render entirely on the server. Markdown parsing, syntax highlighting and HTML generation happen once, at build time. The client receives HTML.
- Navigation is a Server Component that reads the route list from a constants file. No client state.
- The page shell is server-rendered throughout. Zero client JavaScript for the structure.
The client components here are theme toggling and mobile navigation state. That is the whole list. This part is pure Next.js and it behaved identically on both platforms, which is the point: it is the layer that does not care where it runs.
Static generation, and where the HTML actually lives
For a personal site, static generation is the obvious choice. Every page is pre-rendered:
export async function generateStaticParams() {
const posts = getAllPosts();
return posts.map((post) => ({ slug: post.slug }));
}What changes on Workers is what happens to that output. The build produces a directory of static assets and a Worker, and the assets are served through an ASSETS binding declared in wrangler.jsonc rather than by a platform that infers the arrangement for you. Cloudflare's network serves them from wherever the request lands.
Anything not fully static goes through the Worker, and the incremental cache has to be given somewhere to live. On this site that is R2:
// open-next.config.ts
import { defineCloudflareConfig } from '@opennextjs/cloudflare';
import r2IncrementalCache from '@opennextjs/cloudflare/overrides/incremental-cache/r2-incremental-cache';
export default defineCloudflareConfig({
incrementalCache: r2IncrementalCache,
});Three lines, but they are three lines you have to know you need. On Vercel the incremental cache is simply there, and I had never once thought about which storage backed it. That is a fair trade for convenience, and it is worth knowing you made it.
There is no filesystem at runtime
This is the constraint that actually changed the code, and it is the one I would tell anyone moving to Workers to check first.
Workers have no filesystem. The original blog parser read .md files from src/content/blog on demand, which is a perfectly normal thing to do on a Node server and simply does not exist as an option here. fs.readdirSync has nothing to read.
The fix is to move the read to build time. A prebuild step inlines every post into a JSON module that the parser imports instead:
// scripts/generate-blog-sources.mjs
const sources = Object.fromEntries(
fs.readdirSync(BLOG_DIR)
.filter((f) => f.endsWith('.md'))
.map((f) => [f.replace(/\.md$/, ''), fs.readFileSync(path.join(BLOG_DIR, f), 'utf8')])
);It runs as prebuild, so it cannot be forgotten. The interesting part is that this is strictly better than what it replaced. The filesystem read was never necessary; it was just available, so I used it. Removing it took a runtime dependency out of a code path that had no business having one, and the posts were always fixed at build time anyway. The platform constraint found a design flaw that the previous platform had been quietly subsidising.
Images, with no optimiser to lean on
Next's image optimisation is not available here. The config says so plainly:
images: { unoptimized: true }Which means next/image will still lay out and lazy-load, but nothing is converting or resizing anything on the way to the browser. There is no optimiser in the request path at all.
You have two options. Put Cloudflare Images or Image Resizing in front, or do the work at authoring time and serve exactly what you meant to serve. I chose the second, because this site has perhaps a dozen images and an optimiser would have been machinery for a problem I do not have:
<picture>
<source srcSet="/assets/images/me.webp" type="image/webp" />
<img src="/assets/images/me.png" width="120" height="120" decoding="async" />
</picture>Explicit width and height so nothing shifts, a WebP with a PNG fallback, both generated once and committed.
The budget matters more than any of this, and it always did:
- Hero images: under 200KB, WebP
- Blog post images: under 100KB, lazy below the fold
- Author photos: under 30KB
I wrote a version of that list when this post described Vercel, and it was the one part of the image section that survived the move unchanged. Automatic optimisation is a way of making large images less bad. Not having large images is better, and it is portable.
Redirects belong in config, not middleware
The version of this section I originally published showed a middleware.ts with a hand-rolled redirect map, and argued that a static map beats config-file redirects because config is matched in order and grows linearly.
There is no middleware.ts in this repository, and I now think the argument was wrong. Redirects live in next.config.ts:
async redirects() {
return [
{ source: '/blog/rest-mode', destination: '/blog/six-years', permanent: true },
];
}Next matches these before routing, the Worker handles them, and the request never reaches a rendered page. The linear-scan objection is real and completely irrelevant at twenty-two rules, which is what a personal site accumulates in two years. I was optimising a lookup that costs nothing to avoid using the mechanism the framework provides, which is the exact mistake this post claims to be against.
The 301 still matters, and permanence is the part to get right. A permanent redirect tells a search engine to transfer ranking signals and update its index. A 302 leaves the old URL in place.
The deeper reason to prefer config is portability, and I learned it the expensive way. These redirects previously lived in vercel.json. When the site moved to Workers, nothing read that file any more: it sat in the repository root, correct and complete and version-controlled, addressed to a platform that was no longer listening. No error, no warning. Every redirect in it, and every security header beside them, silently stopped existing, and it was four months before I noticed. Configuration belongs at the lowest layer that can enforce it, which here is the framework, because the framework is the thing I am not planning to leave.
Font loading
Fonts are the one section that needed no correction, which is worth noting, because next/font does its work at build time and has no platform in it.
next/fontfor self-hosting. No request to Google's servers at runtime.display: swapso text is never invisible.- Subsetting, because Latin is what this site needs.
import { Inter, Source_Serif_4 } from 'next/font/google';
const inter = Inter({ subsets: ['latin'], display: 'swap' });next/font also computes a size-adjusted fallback, so the swap from system font to web font lands without moving the layout. That is the part of font loading that hurts Core Web Vitals, rather than raw download time.
CSS
Vanilla CSS with custom properties. No CSS-in-JS, no Tailwind, no runtime styling.
I claimed about 15KB here once and never checked it. The source file is 82KB and compresses to roughly 13KB over the wire, so the number I published was wrong and accidentally close to the only figure that matters. Compression is doing the work, not restraint.
Custom properties give theming without JavaScript:
:root {
--color-bg-primary: #ffffff;
--color-text-primary: #1d1d1f;
}
[data-theme="dark"] {
--color-bg-primary: #000000;
--color-text-primary: #f5f5f7;
}Theme switching sets one attribute. Everything referencing the properties updates. No recalculation, no rerender, no flash.
Compute at build time
Everything that can move to the build should, and on Workers this stopped being a preference and became closer to a rule, because the runtime is smaller and the things it cannot do are less negotiable.
- Reading time computed while parsing markdown
- Related posts cross-referenced by tag overlap
- Table of contents extracted from headings during parsing
- Post sources inlined by the prebuild step, per the filesystem section above
- RSS and sitemap generated as static routes
The build runs once. Nobody waits for any of it.
What I do not optimise
- Code splitting per post. They are statically generated. There is no JavaScript to split.
- Service worker caching. Cloudflare's network handles this better than a service worker would, and a service worker is a cache you then have to invalidate correctly forever.
- Preloading every link.
next/linkprefetches on hover, which is enough.
The shape of it
None of this is clever. Server Components, static generation, cached HTML close to the reader, minimal client JavaScript.
What the migration actually taught me is narrower and more useful than any of the individual patterns. A platform that does things for you makes it genuinely hard to tell which parts of your architecture are decisions and which are just the defaults you inherited. Moving is the only reliable audit, and the two things that broke, the filesystem read and the redirects, were both places where I had let the host make a decision I should have been making. The performance was never the fragile part. The assumptions were.