Back to Blog
Nov 2, 20253 min readOnuzulike Anthony

Deploying Next.js to Cloudflare Workers with OpenNext

wrangler.toml, R2 for ISR cache, D1 for tag cache, KV bindings, Hyperdrive for Postgres, and cron triggers — a complete walkthrough.

Full-StackCloudflareNext.jsDeployment

Running Next.js on Cloudflare Workers gives you global edge distribution, zero cold starts, and Cloudflare's entire binding ecosystem — KV, R2, D1, Hyperdrive, cron triggers. The setup is non-obvious though. Here's exactly how NodWatch is deployed.

The Stack

  • OpenNext (@opennextjs/cloudflare) — adapts Next.js output for the Workers runtime
  • Wrangler — local dev and deployment
  • R2 — ISR page cache and fetch cache
  • D1 — tag-based cache invalidation
  • KV — per-user data (recommendations, sessions)
  • Hyperdrive — connection pooling to Neon PostgreSQL

wrangler.toml

toml
name = "nodwatch"
main = "worker.js"
compatibility_date = "2024-09-23"
compatibility_flags = ["nodejs_compat"]
 
routes = [
  { pattern = "nodwatch.online", custom_domain = true },
  { pattern = "*.nodwatch.online/*" }
]
 
[[r2_buckets]]
binding = "NEXT_INC_CACHE_R2_BUCKET"
bucket_name = "nodwatch-cache"
 
[[d1_databases]]
binding = "NEXT_TAG_CACHE_D1"
database_name = "nodwatch-tag-cache"
database_id = "YOUR_D1_ID"
 
[[kv_namespaces]]
binding = "RECOMMENDATIONS_KV"
id = "YOUR_KV_ID"
 
[[hyperdrive]]
binding = "HYPERDRIVE"
id = "YOUR_HYPERDRIVE_ID"
 
[[services]]
binding = "WORKER_SELF_REFERENCE"
service = "nodwatch"
 
[triggers]
crons = ["0 * * * *"]
 
[assets]
directory = ".open-next/assets"
binding = "ASSETS"

open-next.config.ts

ts
import type { OpenNextConfig } from "@opennextjs/cloudflare";
 
export default {
  default: {
    override: {
      wrapper: "cloudflare-node",
      converter: "edge",
      incrementalCache: "cloudflare-r2",
      tagCache: "cloudflare-d1",
      queue: "cloudflare-queue",
    },
  },
} satisfies OpenNextConfig;

The Worker Entry Point

OpenNext outputs .open-next/worker.js. Wrap it to add the cron handler:

js
// worker.js
import worker from "./.open-next/worker.js";
 
export default {
  ...worker,
  async scheduled(event, env, ctx) {
    // Hourly cron — expire download licenses
    ctx.waitUntil(
      fetch(`${env.BETTER_AUTH_URL}/api/downloads/expire-sweep`, {
        method: "POST",
        headers: { "x-cron-secret": env.CRON_SECRET },
      })
    );
  },
};

Hyperdrive for Postgres

Hyperdrive proxies connections to Neon PostgreSQL with built-in connection pooling. In your DB client:

ts
// lib/db.ts
import { drizzle } from "drizzle-orm/neon-http";
import { neon } from "@neondatabase/serverless";
 
function getConnectionString() {
  // In Workers: use Hyperdrive's connection string
  if (typeof process === "undefined" || process.env.NODE_ENV !== "development") {
    // @ts-ignore — injected by Workers runtime
    return globalThis.HYPERDRIVE?.connectionString ?? process.env.DATABASE_URL;
  }
  return process.env.DATABASE_URL;
}
 
const sql = neon(getConnectionString()!);
export const db = drizzle(sql);

Hyperdrive removes the latency of establishing a new Postgres connection on every Worker invocation — critical for edge deployments where connections don't persist.

KV for Per-User Caching

Recommendations are expensive to compute. Cache them per user:

ts
// api/recommendations/route.ts
export async function GET(req: Request) {
  const { userId } = await getSession();
  const kv = (process.env as any).RECOMMENDATIONS_KV;
 
  const cached = await kv?.get(`rec:${userId}`, "json");
  if (cached) return Response.json(cached);
 
  const recommendations = await computeRecommendations(userId);
  await kv?.put(`rec:${userId}`, JSON.stringify(recommendations), {
    expirationTtl: 3600,
  });
 
  return Response.json(recommendations);
}

Local Development

bash
# Install
npm install -g wrangler @opennextjs/cloudflare
 
# Build
npx @opennextjs/cloudflare build
 
# Local preview with real bindings
wrangler dev
 
# Deploy
wrangler deploy

Use wrangler kv:key put, wrangler r2 object put, and wrangler d1 execute to seed data locally.

What Doesn't Work

  • Node.js APIs — Workers use the V8 isolate runtime. fs, path, crypto work via nodejs_compat but some packages still break. Test early.
  • Long-running tasks — Workers have a 30-second CPU limit. Offload to a Queue or Durable Object.
  • WebSockets — Use Durable Objects or Cloudflare Calls instead.

The Cloudflare ecosystem payoff is real: a global edge network, sub-millisecond KV reads, and Hyperdrive eliminating cold-connection penalties makes NodWatch feel snappy anywhere in the world.