anomalous.
← All posts

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:

bash
pnpm add @opennextjs/cloudflare
pnpm add -D wrangler

Create open-next.config.ts. The defaults are enough for all three of our sites:

open-next.config.ts
import { defineCloudflareConfig } from "@opennextjs/cloudflare"

export default defineCloudflareConfig()

Then wrangler.jsonc. This is ours, trimmed slightly:

wrangler.jsonc
{
  "$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" }
  ]
}
  • main points at the Worker the adapter generates. It does not exist until you have run the OpenNext build.
  • nodejs_compat gives the Worker the Node.js APIs Next.js relies on.
  • routes with custom_domain: true makes Wrangler create the DNS record and certificate for each hostname. The zone has to be in the same Cloudflare account.
  • assets serves the static files through the ASSETS binding, so there is no separate bucket or CDN to configure.
  • WORKER_SELF_REFERENCE is a service binding to the Worker itself. The adapter template includes it for its caching features, and its service must match the Worker name. The guesthouse site does not use it.

Finally, scripts in package.json:

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:

next.config.mjs
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 the Host header (a 301 on one, a 308 on the other).
  • This site uses the redirects() config in next.config.mjs, because it has no middleware.

The redirects() version has two traps, and we hit both. Here is the working form:

next.config.mjs
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 literal https://www.anomaloustech.co.za/:path*.
  • Anchor the host regex with ^ and $. OpenNext does not anchor it, so an unanchored anomaloustech\.co\.za also matches www.anomaloustech.co.za and 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 deploy directly. main points at .open-next/worker.js, which only exists after opennextjs-cloudflare build. Always go through pnpm run deploy, or you get "entry-point file was not found".
  • Keep middleware in middleware.ts. Next.js 16 deprecated that name in favour of proxy.ts, but proxy.ts is 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, and sharp if you use it) under allowBuilds in pnpm-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 www or apex DNS record from the old host, wrangler deploy uploads 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 images binding in wrangler.jsonc to enable Cloudflare image optimisation; the other two set images.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 with wrangler secret put or in the dashboard.
  • Pin the Node version in .nvmrc so 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

Need a hand with your own project?

Fractional CTO leadership, CISO services and AI security, from Cape Town to anywhere remote.