💭

Cloudflare WorkersにNext.jsアプリをデプロイする

2025/01/11に公開

はじめに

2024年9月にWorkers Static Assets機能がリリースされ、フルスタックなNext.jsアプリケーションがデプロイ可能になりました。現時点でまだ実験的機能の扱いですが、動作するか試してみます。

※CloudflareのドキュメントはNext.js v14で説明されているため、本記事もv14を前提に進めます。

プロジェクトを作成する

以下のコマンドを実行してプロジェクトを作成します。デプロイは後で行うため、「Do you want to deploy your application?」はNoと回答してください。

npm create cloudflare@latest my-next-app -- --framework=next --experimental

(補足)package.jsonについて

現時点では以下のpackage.jsonが作成されます。Cloudflareのドキュメントはpackage.jsonのscriptsを修正するように指示されていますが、修正は不要です。

package.jsonの内容
{
  "name": "my-next-app",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "next lint",
    "deploy": "opennextjs-cloudflare && wrangler deploy",
    "preview": "opennextjs-cloudflare && wrangler dev",
    "cf-typegen": "wrangler types --env-interface CloudflareEnv env.d.ts"
  },
  "dependencies": {
    "next": "14.2.5",
    "react": "^18",
    "react-dom": "^18"
  },
  "devDependencies": {
    "@cloudflare/workers-types": "^4.20250109.0",
    "@opennextjs/cloudflare": "^0.3.7",
    "@types/node": "^20",
    "@types/react": "^18",
    "@types/react-dom": "^18",
    "eslint": "^8",
    "eslint-config-next": "14.2.5",
    "postcss": "^8",
    "tailwindcss": "^3.4.1",
    "typescript": "^5",
    "wrangler": "^3.101.0"
  }
}

デプロイする

ひとまずデプロイが成功するか確認します。以下のコマンドを実行してください。

npm run deploy

警告やエラーは発生せずデプロイは成功するはずです。ブラウザでhttps://my-next-app.<your_name>.workers.devを開いてみてください。

npm run deploy実行結果の例

参考までにnpm run deploy実行時のログを示します。

$ npm run deploy

> my-next-app@0.1.0 deploy
> opennextjs-cloudflare && wrangler deploy


┌─────────────────────────────┐
│ OpenNext — Cloudflare build │
└─────────────────────────────┘

App directory: /private/tmp/my-next-app
Next.js version : 14.2.5
OpenNext v3.3.1

┌─────────────────────────────────┐
│ OpenNext — Building Next.js app │
└─────────────────────────────────┘


> my-next-app@0.1.0 build
> next build

  ▲ Next.js 14.2.5

   Creating an optimized production build ...
(node:50071) [DEP0040] DeprecationWarning: The `punycode` module is deprecated. Please use a userland alternative instead.
(Use `node --trace-deprecation ...` to show where the warning was created)
(node:50120) [DEP0040] DeprecationWarning: The `punycode` module is deprecated. Please use a userland alternative instead.
(Use `node --trace-deprecation ...` to show where the warning was created)
 ✓ Compiled successfully
 ✓ Linting and checking validity of types
 ✓ Collecting page data
 ✓ Generating static pages (5/5)
 ✓ Collecting build traces
 ✓ Finalizing page optimization

Route (app)                              Size     First Load JS
┌ ○ /                                    5.23 kB        92.3 kB
└ ○ /_not-found                          875 B          87.9 kB
+ First Load JS shared by all            87 kB
  ├ chunks/23-b75664ace61c0abb.js        31.5 kB
  ├ chunks/fd9d1056-2821b0f0cabcd8bd.js  53.7 kB
  └ other shared chunks (total)          1.85 kB


○  (Static)  prerendered as static content


┌──────────────────────────────┐
│ OpenNext — Generating bundle │
└──────────────────────────────┘

Bundling middleware function...
Bundling static assets...
Bundling cache assets...
Building server function: default...
# copyPackageTemplateFiles
⚙️ Bundling the OpenNext server...

# patchWranglerDeps
# updateWebpackChunksFile
 - chunk 347.js
 - chunk 682.js
 - chunk 948.js
Applying code patches:
 - patching require
 - patching `buildId` function
 - patching `loadManifest` function
 - patching next's require
 - patching `findDir` function
 - patching `evalManifest` function
 - patching cacheHandler
 - patching 'require(this.middlewareManifestPath)'
 - patching exception bubbling
 - patching `loadInstrumentationModule` function
 - patching `patchAsyncStorage` call
 - patching `eval("require")` calls
 - patching `require.resolve` call
All 13 patches applied

Worker saved in `/private/tmp/my-next-app/.open-next/worker.js` 🚀

OpenNext build complete.

 ⛅️ wrangler 3.101.0
--------------------

🌀 Building list of assets...
🌀 Starting asset upload...
🌀 Found 31 new or modified static assets to upload. Proceeding with upload...
+ /BUILD_ID
+ /_next/static/3IkHGSNZNy73_7lkbfk4L/_buildManifest.js
+ /_next/static/chunks/app/layout-cacac135ce820ae3.js
+ /next.svg
+ /_next/static/chunks/webpack-d0ceac4fb78a3613.js
+ /cnd-cgi/_next_cache/3IkHGSNZNy73_7lkbfk4L/_not-found.cache
+ /_next/static/media/26a46d62cd723877-s.woff2
+ /_next/static/media/55c55f0601d81cf3-s.woff2
+ /_next/static/media/6d93bde91c0c2823-s.woff2
+ /cnd-cgi/_next_cache/3IkHGSNZNy73_7lkbfk4L/favicon.ico.cache
+ /_next/static/chunks/fd9d1056-2821b0f0cabcd8bd.js
+ /_next/static/3IkHGSNZNy73_7lkbfk4L/_ssgManifest.js
+ /_next/static/chunks/pages/_error-1be831200e60c5c0.js
+ /_next/static/chunks/main-app-8cc085c9d52c8c97.js
+ /_next/static/chunks/app/_not-found/page-05886c10710171db.js
+ /_next/static/media/df0a9ae256c0569c-s.woff2
+ /_next/static/chunks/173-32f9ff9bdfb525b3.js
+ /_next/static/media/581909926a08bbc8-s.woff2
+ /favicon.ico
+ /_next/static/chunks/polyfills-78c92fac7aa8fdd8.js
+ /_next/static/chunks/23-b75664ace61c0abb.js
+ /_next/static/chunks/app/page-41ceab464b24ef75.js
+ /_next/static/chunks/pages/_app-6a626577ffa902a4.js
+ /vercel.svg
+ /cnd-cgi/_next_cache/3IkHGSNZNy73_7lkbfk4L/500.cache
+ /_next/static/media/97e0cb1ae144a2a9-s.woff2
+ /_next/static/css/f451d9dd0f2d5d98.css
+ /cnd-cgi/_next_cache/3IkHGSNZNy73_7lkbfk4L/index.cache
+ /_next/static/media/a34f9d1faa5f3315-s.p.woff2
+ /_next/static/chunks/main-c8553ba68b419844.js
+ /_next/static/chunks/framework-f66176bb897dc684.js
Uploaded 10 of 31 assets
Uploaded 20 of 31 assets
Uploaded 31 of 31 assets
✨ Success! Uploaded 31 files (6.85 sec)

Total Upload: 5238.56 KiB / gzip: 1355.74 KiB
Worker Startup Time: 92 ms
Uploaded my-next-app (23.24 sec)
Deployed my-next-app triggers (0.90 sec)
  https://my-next-app.moutend.workers.dev
Current Version ID: 35c1e4fb-0e51-43ea-92ed-4da5154b8b41

サーバーサイドコンポーネントを実装する

Workersで動的にコンテンツをレンダリングできることを確認するため、大吉・中吉・小吉をランダムに返すおみくじコンポーネントを実装してみます。

実装するのは以下の2つです。

  • src/components/Omikuji.tsx
  • src/app/omikuji/page.tsx

※プロジェクト初期化時に作成されるtsconfig.jsonにpathの設定がされているため、上記2つのファイルを作成するだけで動作します。

src/components/Omikuji.tsx

src/componentsディレクトリを作成し、以下の内容でOmikuji.tsxを作成します。

export default function Omikuji() {
  const results = ['大吉', '中吉', '小吉'];
  const randomIndex = Math.floor(Math.random() * results.length);
  const fortune = results[randomIndex];

  return (
    <div>
      <p>あなたの運勢は…</p>
      <p style={{ fontSize: '2rem', fontWeight: 'bold' }}>{fortune}</p>
    </div>
  );
}

src/app/omikuji/page.tsx

src/app/omikujiディレクトリを作成し、以下の内容でpage.tsxを作成します。

import { unstable_noStore } from "next/cache";
import Omikuji from "@/components/Omikuji";

export default function OmikujiPage() {
  // サーバーサイドでおみくじを引く。
  // Cloudflare WorkersがNext.js v15に対応したらunstable_noStore()からconnection()に変更する。
  // await connection();
  unstable_noStore();

  return (
    <main style={{ padding: '1rem' }}>
      <h1>2025年の運勢は?</h1>
      <p>
        新しい一年が始まりましたね。運勢をチェックして、良いスタートを切りましょう!
      </p>
      <Omikuji />
    </main>
  );
}

動作確認

デプロイしましょう。

npm run deploy

その後、ブラウザでhttps://my-next-app.<your_name>.workers.dev/omikujiを開きます。リロードするたびに大吉・中吉・小吉がランダムに表示されます。

バインディングを利用する

WorkersのバインディングはgetCloudflareContext()で参照できます。試してみましょう。

以下の機能を実装することにします。

  • D1データベースを作成し、omikuji_historyテーブルを定義する。
  • サーバーサイドでリクエストの都度ランダムに大吉・中吉・小吉を選ぶ。
    • 結果をomikuji-historyテーブルにINSERTする。
    • 直近の10件をSELECTする。
    • SELECTした結果を<table>形式でレスポンスする。

なお、実装にはDrizzle ORMを利用します。素のD1バインディングで読み書きしても構わないのですが、結果をTypeScriptのオブジェクトにマッピングするのが手間なのでORMを利用します。Drizzle ORMの使い方については以下の記事も参考にしてください。

Cloudflare D1でDrizzle ORMを使う - Zenn

Drizzle ORMをインストールする

以下のコマンドを実行します。

npm i drizzle-orm dotenv
npm i -D drizzle-kit tsx

drizzle.config.tsを作成する

プロジェクトのルートにdrizzle.config.tsを作成します。以下の内容でファイルを作成してください。

import 'dotenv/config';
import { defineConfig } from 'drizzle-kit';

export default defineConfig({
	out: './drizzle',
	schema: './src/db/schema.ts',
	dialect: 'sqlite',
	driver: 'd1-http',
	dbCredentials: {
		accountId: process.env.CLOUDFLARE_ACCOUNT_ID!,
		databaseId: process.env.CLOUDFLARE_DATABASE_ID!,
		token: process.env.CLOUDFLARE_D1_TOKEN!,
	},
});

D1データベースを作成する

以下のコマンドを実行します。データベースの名前は適当で構いません。

npx wrangler d1 create my-database

wrangler.tomlファイルを編集する

D1バインディングの設定を追加します。

#:schema node_modules/wrangler/config-schema.json
name = "my-next-app"
main = ".open-next/worker.js"

compatibility_date = "2024-09-26"
compatibility_flags = ["nodejs_compat"]

# Minification helps to keep the Worker bundle size down and improve start up time.
minify = true

# Use the new Workers + Assets to host the static frontend files
assets = { directory = ".open-next/assets", binding = "ASSETS" }

# ここから追加

[[d1_databases]]
binding = "DB"
database_name = "my-database"
database_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"

型定義を生成する

以下のコマンドを実行します。

npm run cf-typegen

.envに環境変数を設定する

プロジェクトのルートに.envを配置します。以下の内容でファイルを作成してください。

CLOUDFLARE_ACCOUNT_ID='CloudflareのアカウントID'
CLOUDFLARE_DATABASE_ID='wrangler.tomlファイルに設定したdatabase_id'
CLOUDFLARE_D1_TOKEN='Cloudflareダッシュボードで発行したAPIトークン'

テーブルを定義する

src/dbディレクトリを作成し、以下の内容でsrc/db/schema.tsを作成します。

import { sqliteTable, int, text } from "drizzle-orm/sqlite-core";

export const omikujiHistoryTable = sqliteTable("omikuji_history", {
  id: int().primaryKey({ autoIncrement: true }),
  fortune: text().notNull(),
  createdAt: text("created_at").notNull(),
});

マイグレーションを実行する

以下のコマンドを実行します。

npx drizzle-kit generate
npx drizzle-kit migrate

src/app/d1/page.tsxを実装する

import { unstable_noStore } from "next/cache";
import { getCloudflareContext } from "@opennextjs/cloudflare";
import { drizzle } from "drizzle-orm/d1";
import { desc } from "drizzle-orm";
import { omikujiHistoryTable } from "@/db/schema";

async function insert(env: CloudflareEnv, fortune: string) {
  const db = drizzle(env.DB);
  const record: typeof omikujiHistoryTable.$inferInsert = {
    fortune,
    createdAt: new Date().toISOString(),
  };

  await db.insert(omikujiHistoryTable).values(record);
}

async function select(
  env: CloudflareEnv,
): Promise<(typeof omikujiHistoryTable.$inferSelect)[]> {
  const db = drizzle(env.DB);
  const result = await db
    .select()
    .from(omikujiHistoryTable)
    .orderBy(desc(omikujiHistoryTable.id))
    .limit(10);

  return result;
}

export default async function Page() {
  unstable_noStore();

  const results = ["大吉", "中吉", "小吉"];
  const randomIndex = Math.floor(Math.random() * results.length);
  const fortune = results[randomIndex];

  const { env } = await getCloudflareContext();
  await insert(env, fortune);
  const history = await select(env);

  return (
    <div>
      <h1>おみくじの履歴</h1>
      <table border={1}>
        <thead>
          <tr>
            <th>ID</th>
            <th>Fortune</th>
            <th>Created At</th>
          </tr>
        </thead>
        <tbody>
          {history.map((record) => (
            <tr key={record.id}>
              <td>{record.id}</td>
              <td>{record.fortune}</td>
              <td>{new Date(record.createdAt).toLocaleString()}</td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}

動作確認

デプロイしましょう。

npm run deploy

ブラウザでhttps://my-next-app.<your_name>workers.dev/d1を開くとおみくじの履歴が表示されます。何度かリロードして、直近10件のレコードが取得されていることを確かめてみてください。

おわりに

軽く試す程度であれば特に不具合なく実装できました。ただし、現時点では実験的機能の扱いです。本番環境で利用するにはしばらく様子見ですね。

参考資料

  1. Next.js · Cloudflare Workers docs
  2. Get Started - OpenNext
  3. Bindings - OpenNext

Discussion