
Deploying Next.js to Cloudflare Workers with OpenNext and Wrangler
January 15, 2025
·12 min
|Cloudflare Workers let you run JavaScript on the edge across 300+ data centres worldwide. Next.js was not built for this runtime, but with the right adapter and some configuration, you can deploy a full Next.js App Router application to Workers and serve it globally with very low latency.
This post walks through the process using @opennextjs/cloudflare as the build adapter and Wrangler as the deployment tool. It covers the basic setup, wrangler configuration, subdomain routing, environment variables, CI/CD with GitHub Actions, and the gotchas you will run into along the way.
Why Workers Instead of Pages
Cloudflare Pages is the simpler option for most projects. You connect a GitHub repo, set a build command, and it deploys automatically. For static sites or straightforward Next.js apps, it works well.
Workers give you more control over the request lifecycle. You can intercept requests before they reach your application, rewrite URLs, set headers, and interact with Cloudflare services like KV, Durable Objects, and the cache API directly. If you need custom subdomain routing, per-IP rate limiting, or request-level logic that has to run before your framework processes anything, Workers are the way to go.
The trade-off is complexity. With Pages, deployment is mostly automatic. With Workers, you manage the build pipeline, the wrangler configuration, and occasionally patch things when the build adapter and the framework disagree about how something should work.
The OpenNext Adapter
Next.js was designed for Node.js and later optimised for Vercel's infrastructure. Running it anywhere else requires an adapter that translates between what Next.js expects and what the target platform provides.
@opennextjs/cloudflare is that adapter for Workers. It takes the output of a Next.js build and repackages it into a Worker-compatible format. Static assets go into the asset manifest. Server-side rendering, API routes, and middleware get bundled into a single Worker script. The adapter handles routing, caching, and the translation between the Web Fetch API that Workers use and the Node.js APIs that Next.js expects.
Install it alongside your Next.js project:
npm install @opennextjs/cloudflareThe build command is straightforward:
npx opennextjs-cloudflare buildThis produces a .open-next directory containing the compiled Worker and all static assets. You then deploy it with Wrangler:
npx wrangler deployIn practice, you will probably wrap these in package.json scripts:
{
"scripts": {
"cf:build": "opennextjs-cloudflare build",
"cf:preview": "npm run cf:build && wrangler dev",
"cf:deploy": "npm run cf:build && wrangler deploy"
}
}Wrangler Configuration
The wrangler.toml file defines how the Worker is deployed. A basic setup for a Next.js site looks like this:
name = "my-nextjs-site"
main = ".open-next/worker.js"
compatibility_date = "2024-09-23"
compatibility_flags = ["nodejs_compat"]
pages_build_output_dir = ".open-next"
# Custom domain
[[routes]]
custom_domain = "example.com"The nodejs_compat flag is required because Next.js uses Node.js APIs that are not natively available in the Workers runtime. Without it, you will see errors about missing modules like buffer, crypto, or stream.
If you need KV for things like rate limiting or session storage, add a binding:
[[kv_namespaces]]
binding = "RATE_LIMIT"
id = "your-kv-namespace-id"Serving Multiple Domains from One Worker
A single Cloudflare Worker can serve traffic for multiple domains. This is useful if you want subdomains like blog.example.com or app.example.com to be handled by the same application. Add multiple custom domain routes in your wrangler config:
[[routes]]
custom_domain = "example.com"
[[routes]]
custom_domain = "blog.example.com"
[[routes]]
custom_domain = "app.example.com"Cloudflare creates the DNS records automatically when you deploy. All three domains hit the same Worker, and you can branch logic based on the Host header.
Subdomain Routing for Static Pages
Here is where things get interesting. Say you want clean URLs on subdomains:
blog.example.com/my-post → serves /blogs/my-post
app.example.com/my-tool → serves /apps/my-toolThe obvious approach is Next.js middleware. Read the Host header, detect the subdomain, rewrite the URL with NextResponse.rewrite(). This works for server-rendered pages. It does not work for static pages.
The reason is specific to how OpenNext handles static assets on Cloudflare. When a request comes in, the Worker checks its asset manifest before Next.js middleware runs. If it finds a matching static file, it serves it directly and the middleware never executes. So a request to blog.example.com/my-post causes the Worker to look for /my-post in the asset manifest, which does not exist because the actual file lives at /blogs/my-post. The result is a 404.
Rewrites in next.config.ts have the same limitation. They are processed by Next.js, not by the Worker, so they run after the asset manifest lookup.
The Worker Patch Approach
The workaround is to inject URL rewriting into the Worker script itself, before OpenNext's asset resolution runs. You can do this with a post-build script that patches .open-next/worker.js.
Find this anchor point in the compiled Worker:
const url = new URL(request.url);And inject routing logic right after it:
const subPrefixes = { blog: "/blogs", app: "/apps" };
const host = request.headers.get("host") || url.hostname;
const sub = host.split(".")[0];
if (subPrefixes[sub] && !url.pathname.startsWith(subPrefixes[sub])) {
url.pathname = subPrefixes[sub] + url.pathname;
request = new Request(url, request);
}This runs before OpenNext processes the request, so the asset manifest lookup sees the correct path. A request to blog.example.com/my-post gets rewritten to /blogs/my-post at the Worker level, and OpenNext finds the static file exactly where it expects it.
The double-prefix check prevents a request to blog.example.com/blogs/slug from being rewritten to /blogs/blogs/slug.
Is this hacky? Yes. But if the anchor point disappears in a future version of OpenNext, the build script should fail immediately with a clear error instead of silently deploying a broken Worker. That makes it safe enough for production use.
Environment Variables and KV
Cloudflare Workers handle environment variables differently from Node.js. There is no process.env at the top level. Instead, environment variables and KV bindings are passed through the env parameter of the fetch handler. OpenNext abstracts this away so you can access them using getCloudflareContext() from within API routes:
import { getCloudflareContext } from "@opennextjs/cloudflare";
export async function POST(request: Request) {
const { env } = await getCloudflareContext({ async: true });
const kv = env.RATE_LIMIT;
const apiKey = env.MY_API_KEY;
// ...
}For local development, create a .dev.vars file in the project root:
MY_API_KEY=your-key-here
SOME_SECRET=your-secret-hereWrangler reads this file automatically during wrangler dev. For production, set secrets using wrangler secret put SECRET_NAME so they are not baked into the build output.
Cross-Subdomain Theme Sync
If your site uses dark mode with a library like next-themes, you will run into a problem with subdomains. The library stores the theme preference in localStorage, which is per-origin. A user who enables dark mode on example.com will see light mode when they navigate to blog.example.com.
The fix is a cookie bridge. On every theme change, write the current theme to a cookie with the domain set to .example.com (note the leading dot). This makes the cookie available to all subdomains. Then add a blocking inline script in your root layout that reads this cookie before React hydrates and sets the correct class on the <html> element. By the time next-themes initialises, localStorage already has the right value. No flash of wrong theme.
CI/CD with GitHub Actions
A basic GitHub Actions workflow for deploying to Workers looks like this:
name: Deploy
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run cf:build
- run: npx wrangler deploy
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CF_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }}You need two GitHub secrets. CF_API_TOKEN is a Cloudflare API token with Workers Scripts (Edit) and Workers KV Storage (Edit) permissions. CF_ACCOUNT_ID is your Cloudflare account ID, which you can find on the account home page.
Adding a Next.js build cache step between runs shaves 20 to 30 seconds off the build. Total time from push to live is usually under two minutes.
Gotchas and Things Worth Knowing
Subdomain routing cannot be tested locally. Wrangler dev strips custom Host headers, so the Worker always sees localhost as the host. You have to deploy to a staging environment to test subdomain behaviour. Do not waste time trying to make it work locally.
Static page generation matters.Pages that are statically generated at build time are served from the asset manifest and are extremely fast. Pages that require server-side rendering go through the Worker's JavaScript execution, which adds latency. Use export const dynamic = "force-static" wherever possible.
The Worker has a size limit. Cloudflare Workers have a 10 MB limit after compression on the paid plan and 1 MB on the free plan. A Next.js application with many pages and heavy dependencies can get close to this. Tree-shake your imports and be selective about what goes into server-side code.
CORS needs manual handling. Workers do not add CORS headers automatically. If you have API routes that serve requests from multiple origins, you need to validate the Origin header and set Access-Control-Allow-Origin yourself. Centralise this in a shared utility so every API route handles it consistently.
Cold starts are real but rare. Workers are evicted from edge nodes after periods of inactivity. The first request after eviction takes slightly longer. For most sites this is barely noticeable, but keep it in mind if you are measuring performance percentiles.
Next.js middleware runs after the asset manifest check. This is the root cause of the subdomain routing problem described above. If you are relying on middleware for URL rewriting and some of your pages are statically generated, the middleware will never run for those pages. You need to handle the rewriting at the Worker level.
When to Use This Setup
For most Next.js projects, Vercel or Cloudflare Pages with the OpenNext adapter will be the better choice. Less configuration, fewer moving parts, and you can focus on building your application instead of debugging deployment infrastructure.
Workers make sense when you need fine-grained control over the request lifecycle. Custom subdomain routing, request interception before framework processing, KV-based rate limiting at the edge, or serving multiple domains from a single deployment. If any of those apply to your project, the extra complexity is worth it.
The OpenNext adapter has matured significantly and handles most Next.js features well. The main area where you will still run into friction is anything that requires code to run before the framework processes a request. For those cases, patching the Worker output is a pragmatic solution that works reliably as long as you build in fail-safes for when the adapter changes its output format.