Hosting Next.js on Cloudflare Workers with OpenNext and Wrangler
Christo Goosen4 min read
How we deploy three production Next.js 16 sites to Cloudflare Workers with OpenNext and Wrangler: the setup, the apex-to-www redirect, and the gotchas we hit.
We run three Next.js sites on Cloudflare Workers: this one (anomaloustech.co.za), a guesthouse marketing site (strandgreengables.co.za) and a personal site (christogoosen.co.za). All three use the same pattern: the OpenNext Cloudflare adapter turns the Next.js build into a Worker, and Wrangler ships it. This post covers what that setup looks like and the problems we ran into.
How the pieces fit together
next build produces output that expects a Node.js server. `@opennextjs/cloudflare` takes that output and repackages it into two things under .open-next/: a Worker entry point (worker.js) that handles server rendering, routing, redirects and route handlers, and an assets directory with your static files. Wrangler then uploads both and attaches your custom domains. One Worker serves the pages and the static assets.
All three sites are on Next.js 16 with the App Router, and pnpm as the package manager.
The minimal setup
Add the adapter and Wrangler:
pnpm add @opennextjs/cloudflare
pnpm add -D wranglerCreate open-next.config.ts. The defaults are enough for all three of our sites:
import { defineCloudflareConfig } from "@opennextjs/cloudflare"
export default defineCloudflareConfig()Then wrangler.jsonc. This is ours, trimmed slightly:
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "anomaloustech-co-za",
"main": ".open-next/worker.js",
"compatibility_date": "2026-06-12",
"compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"],
"routes": [
{ "pattern": "www.anomaloustech.co.za", "custom_domain": true },
{ "pattern": "anomaloustech.co.za", "custom_domain": true }
],
"assets": { "directory": ".open-next/assets", "binding": "ASSETS" },
"services": [
{ "binding": "WORKER_SELF_REFERENCE", "service": "anomaloustech-co-za" }
]
}mainpoints at the Worker the adapter generates. It does not exist until you have run the OpenNext build.nodejs_compatgives the Worker the Node.js APIs Next.js relies on.routeswithcustom_domain: truemakes Wrangler create the DNS record and certificate for each hostname. The zone has to be in the same Cloudflare account.assetsserves the static files through theASSETSbinding, so there is no separate bucket or CDN to configure.WORKER_SELF_REFERENCEis a service binding to the Worker itself. The adapter template includes it for its caching features, and itsservicemust match the Workername. The guesthouse site does not use it.
Finally, scripts in package.json:
{
"scripts": {
"preview": "opennextjs-cloudflare build && opennextjs-cloudflare preview",
"deploy": "opennextjs-cloudflare build && opennextjs-cloudflare deploy",
"upload": "opennextjs-cloudflare build && opennextjs-cloudflare upload",
"cf-typegen": "wrangler types --env-interface CloudflareEnv cloudflare-env.d.ts"
}
}pnpm run deploy builds and goes live. pnpm run preview runs the production build locally in workerd, which is much closer to production than next dev. upload pushes a version without deploying it. pnpm run cf-typegen regenerates typed bindings after you change wrangler.jsonc.
For local development, call initOpenNextCloudflareForDev() from next.config.mjs so Cloudflare bindings are available under plain next dev:
import { initOpenNextCloudflareForDev } from "@opennextjs/cloudflare"
initOpenNextCloudflareForDev()Redirecting the apex domain to www
Attach both the apex and the www hostname to the Worker, then redirect one to the other in code. If the apex is not routed to the Worker, there is nothing to answer requests for it. We ended up with two implementations:
- The other two sites redirect in
middleware.ts, matching on theHostheader (a 301 on one, a 308 on the other). - This site uses the
redirects()config innext.config.mjs, because it has no middleware.
The redirects() version has two traps, and we hit both. Here is the working form:
async redirects() {
return [
{
source: '/',
has: [{ type: 'host', value: '^anomaloustech\\.co\\.za$' }],
destination: 'https://www.anomaloustech.co.za/',
permanent: true,
},
{
source: '/:path+',
has: [{ type: 'host', value: '^anomaloustech\\.co\\.za$' }],
destination: 'https://www.anomaloustech.co.za/:path+',
permanent: true,
},
]
}- Use two rules, not one
/:path*rule. Under OpenNext, parameters are only substituted when the match yields some, so a request for/was redirected to a literalhttps://www.anomaloustech.co.za/:path*. - Anchor the host regex with
^and$. OpenNext does not anchor it, so an unanchoredanomaloustech\.co\.zaalso matcheswww.anomaloustech.co.zaand sends it into a redirect loop.
Browsers cache permanent redirects (301 and 308) aggressively. If you ship a broken one, it can keep being replayed on machines that saw it even after you fix the server, so test redirect changes in a private window, or use a temporary redirect until you are sure.
Gotchas
- Do not run
wrangler deploydirectly.mainpoints at.open-next/worker.js, which only exists afteropennextjs-cloudflare build. Always go throughpnpm run deploy, or you get "entry-point file was not found". - Keep middleware in
middleware.ts. Next.js 16 deprecated that name in favour ofproxy.ts, butproxy.tsis forced onto the Node.js runtime, which the adapter did not support when we wrote this. The deprecation warning at build time is expected. Check the OpenNext docs before renaming. - pnpm blocks dependency install scripts by default. Approve the ones the toolchain needs (
workerd,esbuild, andsharpif you use it) underallowBuildsinpnpm-workspace.yaml, otherwise the build or preview fails in confusing ways. - A custom domain can fail to attach on a fresh zone. If Cloudflare imported an existing
wwwor apex DNS record from the old host,wrangler deployuploads the Worker but fails at the domain step. Add the domain from the dashboard (Workers & Pages → your Worker → Settings → Domains & Routes), which offers to overwrite the conflicting record. - Images are a choice. This site adds an
imagesbinding inwrangler.jsoncto enable Cloudflare image optimisation; the other two setimages.unoptimized: true. - Set security headers in one place. On the sites that use middleware for headers or a per-request CSP nonce, we keep them there and not also in
next.config.mjs. Duplicating them produced merged, doubled header values in production. - Secrets stay out of the repo. Locally they live in
.dev.vars; in production set them withwrangler secret putor in the dashboard. - Pin the Node version in
.nvmrcso local and CI builds match.
Is it worth it?
For marketing sites and small apps, we think so: one Worker per site, static assets and server rendering behind the same custom domain, and deploys that are a single command. We have not enabled the R2 incremental cache, because these sites are mostly static; if yours leans on ISR, the OpenNext caching docs are the place to start.
If you want help moving a Next.js project onto Cloudflare, or a review of the security of what you already run there, get in touch.
Keep reading
3 min read
What is a vCISO, and does your South African company need one?
A plain-English guide to vCISOs for South African companies: what a vCISO does, how it differs from a full-time CISO, and how it fits with POPIA and ISO 27001.
Read post2 min read
Fractional CTO in South Africa: when it works and how engagements run
What a fractional CTO does, when a South African startup or scale-up should use one instead of a full-time hire, and how a typical engagement runs.
Read postNeed a hand with your own project?
Fractional CTO leadership, CISO services and AI security, from Cape Town to anywhere remote.