Rebuilding my website with Next.js 16 on Cloudflare Workers

· 4 min read
nextjscloudflareopennext

The previous version of this site had exactly one page, a typing animation, and a navbar that linked to three pages that didn't exist. It also imported framer-motion without having it in package.json, which means it technically couldn't even build. I know, because I wrote it.

Time for a proper rebuild. This post covers the stack I picked, why, and the handful of things that actually required thought.

The stack

  • Next.js 16 — App Router, React Server Components, Turbopack builds
  • Cloudflare Workers — via the OpenNext adapter
  • Tailwind CSS v4 — CSS-first config, no tailwind.config.ts
  • MDX — posts live in the repo, compiled to HTML at build time with unified and shiki
  • D1 — a tiny SQLite database at the edge, counting page views

At work I run a serverless-first AWS shop, so you might expect Lambda here. But for a personal site, Cloudflare's model is hard to argue with: the static pages are served from their CDN directly, the dynamic bits run in a V8 isolate with near-zero cold start, and the bill for a site like this rounds to zero.

How OpenNext fits

Next.js officially deploys "anywhere Node.js runs" — but a Worker is not Node. The @opennextjs/cloudflare adapter bridges that gap: it takes the standalone output of next build and repackages it into a Worker bundle, with static assets uploaded to Cloudflare's asset storage.

The result deploys with two commands:

npx opennextjs-cloudflare build
npx opennextjs-cloudflare deploy

Almost everything on this site is prerendered at build time, so the Worker only really wakes up for three things: the contact form, the view counters, and serving whatever the CDN doesn't have cached.

View counters with D1

Every blog post shows a view count. The implementation is a D1 table with two columns and a route handler:

import { getCloudflareContext } from "@opennextjs/cloudflare";
 
export async function POST(
  _req: Request,
  { params }: { params: Promise<{ slug: string }> },
) {
  const { slug } = await params;
  const { env } = await getCloudflareContext({ async: true });
 
  const row = await env.DB.prepare(
    `INSERT INTO views (slug, count) VALUES (?, 1)
     ON CONFLICT (slug) DO UPDATE SET count = count + 1
     RETURNING count`,
  )
    .bind(slug)
    .first<{ count: number }>();
 
  return Response.json({ count: row?.count ?? 0 });
}

getCloudflareContext() is the adapter's escape hatch to the Workers runtime — bindings, execution context, all of it. During local dev, initOpenNextCloudflareForDev() in next.config.ts wires the same bindings into next dev, backed by a local SQLite file. It's a genuinely good developer experience.

Sharp edges

Not everything was smooth, in case you're planning the same move:

  1. There is no filesystem at runtime. My first version read MDX files with fs.readFileSync inside the request path. Works in next dev, works in next build — silently 404s in the Worker, because the deployed bundle has no disk. Content must become a build artifact: a script now compiles every post to HTML in a JSON manifest that gets imported statically — which also keeps the whole markdown/shiki toolchain out of the worker bundle.
  2. SSG routes need an explicit cache. The adapter's default incremental cache is a no-op, so every page from generateStaticParams 404s in production while plain static pages work fine. The fix is one line in open-next.config.ts: staticAssetsIncrementalCache, which serves the prerendered HTML from Workers static assets.
  3. Node middleware isn't supported. Next 16 renamed middleware.ts to proxy.ts and runs it on the Node runtime — which the adapter can't do yet. I avoided middleware entirely; this site doesn't need it.
  4. Watch your bundle size. The free Workers plan caps compressed bundles at 3 MiB. Dynamic OG image generation (ImageResponse) alone pulls in roughly 2 MiB of WASM. Tight, but it fits — check the bundle report before committing to a design.
  5. Pin esbuild if npm resolves it weirdly. Wrangler and the adapter both ship esbuild; a hoisting conflict gave me a version-mismatch error on install. One overrides entry in package.json fixed it.

What carried over

One thing survived the rewrite: the programming jokes. The old "under construction" page told you a random joke while you waited for a website that was not, in fact, under construction. The jokes now live in the footer. Some technical debt is worth keeping.