Deploying Next.js to Cloudflare Workers: What We Learned
Notes from moving a Next.js site to Cloudflare Workers with OpenNext: the file system, prerendered pages, forms, email and static files, with the fixes we used.
We moved this website to Cloudflare Workers using the OpenNext adapter. The site is small, and the move still surfaced five problems that the documentation did not make obvious. This article lists each one, the symptom we saw and what fixed it.
Our setup: Next.js with the App Router, every page prerendered at build time, one API route for the contact form.
1. There is no file system at runtime
Symptom. Our content lived in Markdown files that were read with fs when a page rendered. This works on a Node.js server. On Workers, the project files are not available at runtime.
Prerendered pages hid the problem at first, because they are rendered during the build. It appeared in code that runs per request, such as a layout that checked whether any case studies existed.
Fix. Move file reading to build time. A small script reads the Markdown files and writes a JSON manifest, and the application imports that manifest as a module.
// scripts/build-content.mjs (simplified)
const manifest = {
blog: readCollection("blog"),
work: readCollection("work"),
};
fs.writeFileSync("src/content/generated.json", JSON.stringify(manifest));
The script runs before next build through a prebuild entry in package.json.
2. Runtime Markdown compilers are a poor fit
Symptom. We used an MDX library that compiles content when the page renders. The adapter reported errors copying its dependencies during bundling. Libraries of this kind also generate code at runtime, which Workers restrict.
Fix. Our content was plain Markdown, so we converted it to HTML in the same build script and render the HTML directly. The runtime dependency disappeared and the Worker bundle got smaller.
If your content needs interactive components inside it, compile the MDX at build time instead.
3. Prerendered dynamic routes returned 404
Symptom. Static pages such as /about worked. Pages from dynamic routes with generateStaticParams, such as /blog/[slug], returned 404, with a NoFallbackError in the logs.
Cause. The adapter serves prerendered pages from an incremental cache. With no cache configured, it tried to render the page on demand, and our routes do not allow that.
Fix. Configure the static assets incremental cache, which reads prerendered pages from the Worker's static assets:
// open-next.config.ts
import { defineCloudflareConfig } from "@opennextjs/cloudflare";
import staticAssetsIncrementalCache from "@opennextjs/cloudflare/overrides/incremental-cache/static-assets-incremental-cache";
export default defineCloudflareConfig({
incrementalCache: staticAssetsIncrementalCache,
});
One more detail: the cache is populated by the adapter's preview and deploy commands. Running wrangler dev directly after a build skips that step, and the 404s remain.
4. Form posts were answered from the page cache
Symptom. Our contact form submitted to the same URL as the page that contained it. With cache interception enabled, a POST to that URL returned the cached page. The form logic never ran, and no error was shown.
Fix. Two changes. We left cache interception off, and we moved the form to its own API route, which the page cache does not touch.
// src/app/api/contact/route.ts
export async function POST(request: Request) {
const body = await request.json();
// validate, then send
}
A separate endpoint is also easier to test, since you can call it directly.
5. Static .html files were redirected
Symptom. Search engine ownership verification asks you to host a file such as google1234.html. Workers static assets redirected that URL to the same path without the extension, and verification could fail on the redirect.
Fix. Turn off HTML handling for assets, so that files are served at their exact path:
// wrangler.jsonc
"assets": {
"directory": ".open-next/assets",
"binding": "ASSETS",
"html_handling": "none"
}
Sending email without a third-party service
Cloudflare Email Routing provides a send_email binding. If the recipient is a verified destination address on your account, a Worker can send to it with no API key:
// wrangler.jsonc
"send_email": [
{ "name": "EMAIL", "destination_address": "you@example.com" }
]
This suits contact forms, where every message goes to your own inbox. It does not replace a transactional email service for messages to your customers.
A problem that solved itself
For a few minutes after attaching the custom domain, some requests failed during the TLS handshake. The success rate rose from about half to all requests within roughly fifteen minutes, with no change on our side. If you see this straight after adding a domain, wait before you start debugging.
Was it worth it?
For a content site with one form, yes. Hosting costs are close to zero, pages are served from a global network, and the domain, DNS, hosting and email routing are managed in one place.
The cost was about half a day spent on the problems above. A larger application that relies on on-demand rendering, image optimization or scheduled revalidation would need more planning, because each of those needs its own configuration on Workers.
Checklist
Before moving a Next.js site to Workers:
- Find every use of
fsand move it to build time - Replace libraries that compile or evaluate code at runtime
- Configure an incremental cache if you have prerendered dynamic routes
- Test with the adapter's preview command, which runs in the Workers runtime
- Put forms on their own API routes
- Test every form in the real runtime before going live
You can read more about how this site was built in our case study. If you are planning a similar move, get in touch or see our web development services.