如何使用 OpenNext 和 D1 將 Next.js App Router 應用部署到 Cloudflare Workers
Deploy a Next.js App Router App to Cloudflare Workers with OpenNext and a D1 Database
這篇教學示範如何透過 OpenNext Cloudflare adapter,將 Next.js App Router 應用部署到 Cloudflare Workers,並以 D1 作為資料庫。
For a long time the honest answer to "can I run a full Next.js App Router app on Cloudflare Workers" was "sort of, with caveats." That changed with the OpenNext Cloudflare adapter. It takes a standard next build output and repackages it into a single Worker, so your server components, route handlers, and static assets all run on Cloudflare's edge, and you get to bind native Cloudflare resources like D1 directly into your request handlers.
I deployed a small prompt-injection challenge app this way, and in this tutorial I will walk through the technique end to end: the config files that actually matter, the scripts, the D1 binding, and the one caveat that will trip you up if nobody warns you about it.
What you are building toward
Three things have to line up:
- A Next.js app that builds normally with
next build. - The
@opennextjs/cloudflareadapter, which converts that build into.open-next/worker.js. - A
wrangler.jsoncthat tells the Workers runtime where the worker and assets live, and which bindings (here, D1) to attach.
Start by installing the pieces into an existing Next.js project:
npm install @opennextjs/cloudflare
npm install -D wrangler @cloudflare/workers-types
The Next.js config
You barely touch next.config.mjs. The one thing worth setting is marking any Node-only package as external so it does not get pulled into the edge bundle. In my app that package was better-sqlite3, used only for local development:
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
experimental: {
serverComponentsExternalPackages: ["better-sqlite3"],
},
};
export default nextConfig;
The OpenNext config
Create open-next.config.ts at the project root. For most apps the defaults are genuinely all you need, because the adapter handles the packaging and the D1 binding comes from Wrangler, not from here:
import { defineCloudflareConfig } from "@opennextjs/cloudflare";
export default defineCloudflareConfig({});
You only reach into this file when you want to override things like the incremental cache or a queue. A plain app with a database binding does not.
The Wrangler config
This is the file that does the real work. Here is the wrangler.jsonc I use, with comments explaining every non-obvious line:
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "injection-arena",
"main": ".open-next/worker.js",
"workers_dev": false,
"preview_urls": false,
"compatibility_date": "2025-03-25",
"compatibility_flags": ["nodejs_compat"],
"assets": {
"directory": ".open-next/assets",
"binding": "ASSETS"
},
"d1_databases": [
{
"binding": "DB",
"database_name": "injection-arena",
"database_id": "PASTE_YOUR_DATABASE_ID_HERE"
}
]
}
The load-bearing details:
-
mainpoints at.open-next/worker.js, not your source. That file only exists after the OpenNext build runs, so do not expect it in a fresh clone. -
compatibility_flags: ["nodejs_compat"]is required. Next.js and the adapter rely on Node.js built-ins, and without this flag the Worker will fail at runtime. -
The
assetsblock serves everything OpenNext emits into.open-next/assets(your static files) and exposes them through theASSETSbinding the worker uses internally. -
The
d1_databasesentry is what makesenv.DBexist at runtime. Thebindingname is how you reach it in code,database_nameis the human name, anddatabase_idis the real identifier from Cloudflare.
Create the D1 database
Run this once and paste the returned id into wrangler.jsonc:
npx wrangler d1 create injection-arena
Then put your schema in migrations/0001_init.sql. One D1-specific gotcha: D1 manages its own journaling, so do not include PRAGMA journal_mode = WAL or other connection pragmas that work fine in local SQLite. Just the tables:
CREATE TABLE IF NOT EXISTS attempts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
nickname TEXT NOT NULL,
challenge_id TEXT NOT NULL,
input TEXT NOT NULL,
cracked INTEGER NOT NULL,
reason TEXT NOT NULL,
points INTEGER NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
Apply it with a script (see below).
The scripts
These are the four scripts worth wiring into package.json:
{
"scripts": {
"preview": "opennextjs-cloudflare build && wrangler dev",
"deploy": "opennextjs-cloudflare build && wrangler deploy",
"cf:migrate": "wrangler d1 migrations apply injection-arena"
}
}
preview builds and runs the Worker locally so you catch edge-runtime problems before shipping. deploy builds and pushes it live. cf:migrate applies your SQL migrations. Add --remote to the migrate command when you want to hit the production database rather than the local one.
Reading the binding in your code
The binding surfaces through the adapter's Cloudflare context. You do not import anything Cloudflare-specific at module top level; you fetch the context inside the request path:
import { getCloudflareContext } from "@opennextjs/cloudflare";
const { env } = getCloudflareContext();
const row = await env.DB
.prepare("SELECT COUNT(*) AS n FROM attempts WHERE session_id = ?")
.bind(sessionId)
.first<{ n: number }>();
To make env.DB type-safe, declare it once in a cloudflare-env.d.ts:
import type { D1Database } from "@cloudflare/workers-types";
declare global {
interface CloudflareEnv {
DB: D1Database;
}
}
export {};
A pattern I found worth the effort: keep the D1 lookup behind a function that returns null when the binding is absent, and fall back to local SQLite for next dev and tests. That way the same query strings run in both places and you never boot a Workers-only code path on your laptop.
The one honest caveat
Native modules do not run on Workers. The Workers runtime is not Node.js; it is a V8 isolate with Node compatibility shims. That means a native addon like better-sqlite3, which compiles to a .node binary, cannot execute on the edge. This is not an OpenNext limitation you can flag your way around, it is fundamental to the platform.
The practical consequence is architectural, not fatal: use native packages only in code paths that never reach the Worker. In my app better-sqlite3 lives entirely in the local and test backend, loaded lazily through createRequire so the OpenNext build never tries to bundle it, while production talks to D1 through the binding. Decide your edge story per dependency before you deploy, not after.
Ship it
With the database created, the id pasted, and migrations applied, the whole loop is:
npm run cf:migrate -- --remote
npm run deploy
That builds the OpenNext worker and pushes it to Cloudflare with D1 attached. Your App Router app is now running on Workers, backed by a real SQL database at the edge.
If you want to see all of this wired together in a working repo, including the dual local-and-D1 backend and the full migration, the app I built this from is open source at github.com/AgentPostmortem/injection-arena. Clone it, read the four config files side by side, and the technique clicks fast.
來源:DEV Community · dev.to