Rebuilding my website with Next.js 16 on Cloudflare Workers
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 deployAlmost 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:
- There is no filesystem at runtime. My first version read MDX files with
fs.readFileSyncinside the request path. Works innext dev, works innext 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. - SSG routes need an explicit cache. The adapter's default incremental
cache is a no-op, so every page from
generateStaticParams404s in production while plain static pages work fine. The fix is one line inopen-next.config.ts:staticAssetsIncrementalCache, which serves the prerendered HTML from Workers static assets. - Node middleware isn't supported. Next 16 renamed
middleware.tstoproxy.tsand runs it on the Node runtime — which the adapter can't do yet. I avoided middleware entirely; this site doesn't need it. - 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. - 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
overridesentry inpackage.jsonfixed 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.