The Cloudflare Workers Next.js Trap Map
This portfolio runs on Cloudflare Workers via the @opennextjs/cloudflare adapter. The stack is good — edge performance, no cold starts, Cloudflare's global network. But the deployment is not a drop-in from Vercel. There are constraints, and they are not always well-documented in one place.
This is the guide that did not exist when this site was being built. Eight traps, in roughly the order you will hit them.
One note on the adapter: this site uses @opennextjs/cloudflare with Wrangler. The Open Next project provides a compatibility layer that makes Next.js deployable beyond Vercel — but it is not magic. The adapter handles routing and server functions, but it cannot simulate a Node.js runtime. That is the root cause of most of these traps.
Trap 1: @vercel/og Does Not Work
Symptom: Build succeeds, but the OG image route throws at runtime or import fails entirely.
The default Next.js OG documentation — and many tutorials — point to @vercel/og. That package does not work on Cloudflare Workers. It is a Vercel-specific package that relies on Vercel's edge runtime internals. Installing it and importing from it on CF Workers will either fail at build time or produce a runtime error when the route is first hit.
Wrong:
import { ImageResponse } from '@vercel/og'Fix:
import { ImageResponse } from 'next/og'next/og is the framework-native version. It ships with Next.js and works on CF Workers. The API is identical — it is a drop-in replacement. Do not install @vercel/og.
If you already have it installed, uninstall it:
npm uninstall @vercel/ogThe OG route on this site generates dynamic images for each blog post and uses next/og exclusively.
Trap 2: export const runtime = 'edge' Breaks Routes
Symptom: Route handler fails to deploy, throws at runtime, or produces an error about conflicting runtime directives.
Cloudflare Workers already runs on the edge — it is the entire execution model. Adding export const runtime = 'edge' in a route file tells Next.js to use its own internal edge runtime, which is a different thing. The CF Workers adapter and the Next.js edge runtime directive conflict. The result is either a deploy failure or a silent breakage in production.
Wrong:
// app/api/og/route.tsx
export const runtime = 'edge'
export async function GET() { ... }Fix: Remove the runtime export entirely. The CF adapter handles this automatically.
// app/api/og/route.tsx
export async function GET() { ... }This was the first thing removed from the OG route when it started failing. The fix was non-obvious because the error message did not clearly blame the runtime export. If a route works locally but breaks on CF Workers, check for a runtime directive.
Trap 3: next.config.ts Is Not Supported
Symptom: Build fails with a config format error, or the adapter cannot load the config properly.
The @opennextjs/cloudflare adapter expects next.config.mjs. A TypeScript config file (next.config.ts) — which is supported in newer versions of Next.js standalone — does not play well with the CF adapter build pipeline. The error is often a module loading failure or a cryptic Wrangler build error.
Wrong:
next.config.ts ← does not work with @opennextjs/cloudflare
Fix:
next.config.mjs ← use this
If you want TypeScript type annotations in the .mjs file, use JSDoc:
// next.config.mjs
/** @type {import('next').NextConfig} */
const nextConfig = {
// your config
}
export default nextConfigYou get type checking on the config object without needing TypeScript compilation for the config file itself.
Trap 4: Filesystem Access Fails at Runtime
Symptom: fs.readdirSync, fs.readFileSync, or glob calls work during development but throw at runtime in production with an error about the filesystem not being available.
CF Workers has no persistent filesystem at request time. This is a fundamental property of the execution model, not a bug. The build environment — where Wrangler bundles your app — does have a filesystem. Your MDX content files exist during next build. But when a request comes in and your Worker runs, it is a V8 isolate with no disk access. Any code that reads from the filesystem at runtime will throw.
The insidious version of this is code that calls fs during module initialization (not inside a request handler). That code runs when the Worker cold-starts, not when a user hits the route. It fails on first request, not at deploy time.
Wrong:
import fs from 'fs'
import path from 'path'
// This runs at module init or request time — both fail on CF Workers
export function getAllPosts() {
const dir = path.join(process.cwd(), 'content/blog')
return fs.readdirSync(dir).map(file => parsePost(file))
}Fix: Static import manifest.
// src/lib/blog-manifest.ts
export const blogManifest: BlogPost[] = [
{ slug: "my-post", title: "My Post", date: "2026-03-20", tags: [...], readingTime: 5, published: true },
// all posts registered explicitly
]getAllPosts() just returns this array. No filesystem access. No runtime scanning. The manifest is imported at build time and bundled into the Worker.
It is also better than scanning for other reasons beyond CF compatibility. See Why Static Blog Manifests Beat Filesystem Scanning for the full argument.
Trap 5: next/image Optimization Does Not Run
Symptom: Images on the site show as broken in production. The network tab shows 500 errors for /_next/image?url=... requests. Or images render locally but are missing after deploying.
The Next.js image optimization server (/_next/image) runs as part of the Node.js server process. CF Workers does not run a persistent Node.js server. When next/image tries to make an optimization request, there is no server to handle it.
Wrong: Using next/image without any configuration on CF Workers.
// This will break in production — optimization endpoint does not exist
import Image from 'next/image'
<Image src="/photo.jpg" width={800} height={600} alt="..." />Fix: Disable image optimization and serve images directly from /public.
// next.config.mjs
const nextConfig = {
images: {
unoptimized: true,
},
}With unoptimized: true, next/image skips the optimization server and serves the source file directly. Images load from the static asset bundle via Cloudflare's ASSETS binding.
For most portfolios, blogs, and content sites, this trade-off is fine. The images are already sized correctly for their use case. You lose automatic WebP conversion and lazy resize — you keep Cloudflare's CDN and edge delivery.
Trap 6: nodejs_compat Compatibility Flag Is Required
Symptom: Node.js built-ins (Buffer, crypto, stream, etc.) throw at runtime.
CF Workers is not a Node.js environment by default. Node.js built-ins are not available unless you opt in via the nodejs_compat flag in wrangler.toml.
Fix:
# wrangler.toml
compatibility_flags = ["nodejs_compat"]Add this if anything in your dependency tree uses Node.js APIs. Next.js itself uses some.
Trap 7: Dynamic Routes Need generateStaticParams
Symptom: Dynamic route pages (/blog/[slug], /work/[slug]) return 404 in production even though they work in development.
CF Workers deploys your app as static files + a Worker for server functions. Dynamic routes need to be pre-rendered at build time. Without generateStaticParams, the CF adapter does not know what params to pre-generate.
Wrong:
// app/blog/[slug]/page.tsx
export default function BlogPost({ params }: { params: { slug: string } }) {
// ...
}Fix:
// app/blog/[slug]/page.tsx
export function generateStaticParams() {
return blogManifest.map(post => ({ slug: post.slug }))
}
export default function BlogPost({ params }: { params: { slug: string } }) {
// ...
}All dynamic routes in this site use generateStaticParams. The blog manifest makes this trivial — iterate it, return the slugs.
Trap 8: initOpenNextCloudflareForDev() Must Be Called in next.config.mjs
Symptom: Local development with wrangler dev or next dev does not behave like the CF Workers environment. Bindings are missing. Dev and prod diverge.
The @opennextjs/cloudflare adapter ships a dev initializer that patches the local environment to approximate the CF Workers runtime. Without it, you develop against a Node.js environment that hides CF-specific failures until production.
Fix:
// next.config.mjs
import { initOpenNextCloudflareForDev } from '@opennextjs/cloudflare'
initOpenNextCloudflareForDev()
/** @type {import('next').NextConfig} */
const nextConfig = { ... }
export default nextConfigCall it at the top of the config, outside any export. It's a no-op in non-development environments.
The Pattern
Looking at these eight traps together, the pattern is clear: CF Workers is a restricted environment compared to Vercel's Node.js runtime. No filesystem at runtime. No persistent server. No image optimization server. No Vercel-specific packages.
Every constraint forced a better architecture:
- No
@vercel/og→ usenext/og(the right package anyway) - No filesystem at runtime → static manifest (explicit, typed, no magic)
- No image optimization → serve directly (simpler, faster for static assets)
- No
runtime = 'edge'export → clean route handlers
The constraints are not arbitrary. CF Workers is a different execution model: stateless, edge-first, no server. Once you internalize that model, the fixes become obvious. The site works. The constraints hold.
If you are deploying Next.js to Cloudflare Workers: start with this list. Most of these will hit you in order.