Deploying a Next.js Site to Cloudflare Workers - The Full Walkthrough

March 16, 2026
cloudflareworkersnext.jsdeploymentdevops

This site runs on Cloudflare Workers. Not Pages. Not Vercel. Workers. It took a few attempts to get right. Here's the full process for anyone doing the same thing.

Why Workers Instead of Pages

Cloudflare has two hosting products - Pages and Workers. Pages is simpler but limited. Workers give you:

  • Full server-side rendering capability (not just static)
  • Access to Cloudflare KV, Durable Objects, R2 storage
  • Custom routing and middleware at the edge
  • The same global network (300+ data centers)
  • No cold starts on the free tier

For a static blog you could use Pages. But I wanted the flexibility to add API routes, server-side features, and edge logic later without migrating platforms.

Prerequisites

  • A Cloudflare account (free tier works)
  • A domain pointed to Cloudflare DNS
  • A GitHub repo with your Next.js project
  • Node.js 18+

Step 1: Project Setup

Start with a standard Next.js project:

npx create-next-app@latest my-site --typescript --tailwind --app
cd my-site

Make sure your next.config.ts does NOT have output: "export". Workers needs the full Next.js build, not a static export:

import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  images: {
    unoptimized: true,
  },
};

export default nextConfig;

images.unoptimized is needed because Cloudflare Workers don't support Next.js Image Optimization out of the box.

Step 2: Install OpenNext Adapter

Cloudflare Workers can't run Next.js natively. The OpenNext adapter converts a Next.js build into a Worker-compatible bundle.

npm install @opennextjs/cloudflare wrangler --save-dev

Create open-next.config.ts in the project root:

import { defineCloudflareConfig } from "@opennextjs/cloudflare";
export default defineCloudflareConfig({});

Create wrangler.jsonc:

{
  "$schema": "node_modules/wrangler/config-schema.json",
  "name": "my-site",
  "compatibility_flags": ["nodejs_compat"],
  "compatibility_date": "2025-09-27",
  "assets": {
    "directory": ".open-next/assets",
    "binding": "ASSETS"
  },
  "main": ".open-next/worker.js"
}

Add build scripts to package.json:

{
  "scripts": {
    "build:cloudflare": "npx @opennextjs/cloudflare build",
    "preview:cloudflare": "npx wrangler dev",
    "deploy": "npm run build:cloudflare && npx wrangler pages deploy .open-next/assets --project-name=my-site"
  }
}

Step 3: Test Locally

npm run build:cloudflare
npm run preview:cloudflare

Open http://localhost:8787. If you see your site, the adapter is working.

Step 4: Connect to Cloudflare

In the Cloudflare dashboard:

  1. Go to Workers & Pages
  2. Create a new Worker
  3. Connect your GitHub repository
  4. Set build settings:
    • Build command: npx @opennextjs/cloudflare build
    • Build output directory: leave empty (wrangler handles it)

Cloudflare will clone your repo, run the build, and deploy the Worker.

Step 5: Custom Domain

  1. In your Worker settings, go to Triggers (or Custom Domains)
  2. Add your domain (e.g., oljasz.com)
  3. Cloudflare automatically handles SSL and DNS routing

If your domain is already on Cloudflare DNS, the whole process takes about 30 seconds.

What Went Wrong (Lessons Learned)

Filesystem Access Doesn't Work at Runtime

My blog reads Markdown files from a content/blog/ directory using fs.readFileSync. This works fine during next build because the build runs on a real machine with a real filesystem. But at runtime on Workers, there is no filesystem.

The fix: make sure all pages that use fs are pre-rendered at build time. Add export const dynamic = "force-static" to any page that reads files:

export const dynamic = "force-static";

This tells Next.js to render the page during build, not at request time. The HTML gets baked into the Worker bundle.

Blog Listing Was Empty

Even with force-static, my blog listing page was empty in production. The page used fs.readdirSync to scan the content directory. During the OpenNext build, the working directory is different from where the content files live.

The fix: hardcode the post metadata in the listing page instead of scanning the filesystem. Individual post pages work fine because they're pre-rendered via generateStaticParams which runs during next build.

// Don't do this on Workers:
const posts = fs.readdirSync("content/blog");

// Do this instead:
const posts = [
  { slug: "my-post", title: "My Post", date: "2026-03-15", ... },
];

Static Export Breaks OpenNext

If you have output: "export" in next.config.ts, the Next.js build generates static HTML files in an out/ directory. But OpenNext expects a full .next/ build with server functions. The build fails with:

ENOENT: no such file or directory, open '.next/standalone/.next/server/pages-manifest.json'

Remove output: "export" and let OpenNext handle the conversion.

Wrangler Auto-Config Runs Twice

When you run npx wrangler deploy, Wrangler detects Next.js and tries to auto-configure OpenNext. This adds packages, modifies package.json, creates config files, and runs the OpenNext build itself. If you already have these set up manually, it creates conflicts.

To avoid this, commit your wrangler.jsonc, open-next.config.ts, and build scripts before deploying. Wrangler won't override existing configs.

deploy.resources.reservations vs runtime: nvidia

Not a Cloudflare issue, but worth mentioning since it bit me on the GPU server side. Docker Compose's deploy.resources.reservations.devices syntax for GPU access only works with Docker Swarm. With regular docker compose, use runtime: nvidia instead. Unrelated to Workers but cost me hours of debugging when Ollama was running at CPU speed inside a container.

The Build Pipeline

Final flow:

git push
  -> Cloudflare detects push
  -> Clones repo
  -> Runs: npx @opennextjs/cloudflare build
    -> Runs: next build (generates .next/)
    -> OpenNext converts .next/ to .open-next/
    -> Bundles worker.js + static assets
  -> Deploys to 300+ edge locations
  -> Custom domain routes through Cloudflare DNS
  -> SSL automatic

Total deploy time: about 60 seconds from push to live.

Cost

Free tier covers:

  • 100,000 requests/day
  • 10ms CPU time per request (plenty for static pages)
  • Custom domains
  • SSL
  • DDoS protection

For a personal site or blog, you'll never hit these limits. I haven't paid anything for hosting this site.

Files to Add to .gitignore

.next
.open-next
.wrangler
.dev.vars

The .open-next directory is generated by the build and doesn't need to be in version control. Cloudflare builds it fresh on every deploy.

Summary

Workers with OpenNext is the right choice if you want server-side capability with Cloudflare's edge network. For a pure static site, Pages would be simpler. But the flexibility of Workers means you won't need to migrate when you add your first API route or server-side feature.

The main thing to watch out for: no filesystem at runtime. Pre-render everything you can during build, hardcode what you can't, and test locally with wrangler dev before pushing.