---
title: "Next.js 15 on Cloudflare Workers: Performance Patterns That Matter"
description: "Performance patterns for Next.js 15 on Cloudflare Workers via OpenNext: Server Components, static generation, the missing filesystem, images without an optimiser, redirects in config, and font and CSS budgets."
date: 2025-11-09
updated: 2026-09-21
category: Science
readingTime: "6 min read"
---


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:

```typescript
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:

```typescript
// 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:

```javascript
// 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:

```typescript
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:

```html
<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`:

```typescript
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.

1. **`next/font`** for self-hosting. No request to Google's servers at runtime.
2. **`display: swap`** so text is never invisible.
3. **Subsetting**, because Latin is what this site needs.

```typescript
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:

```css
: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/link` prefetches 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.
