こんにちは。 id:rokuokun です。 n 番煎じ感はありますが休日に表題のことをやりました。 その時のメモにいい感じの説明を付け加えて記事らしい感じに仕立てたものを残します。
プロジェクト作成
お馴染みのコマンドでCloudflare Workers で Next.js を動かす雛形を作ってもらいます。
このコマンドを実行すると最後にWorkersへデプロイするかどうか尋ねられますがお好みでどうぞ。 僕はこの後にOpenNextでちゃんとデプロイできるか試したかったのでそのままデプロイしました。当たり前ですが、これでデプロイされるのは普通の Next.js です。
$ npm create cloudflare@latest -- --framework=next --platform=workers
OpenNext に置き換える
やることは公式ドキュメントに懇切丁寧に書かれています。 この記事では自分がやったことのみを記載します。
1. Install @opennextjs/cloudflare
特に何も考えずに入れます。
$ npm install @opennextjs/cloudflare@latest
2. Install Wrangler
Wrangler を入れましょう。何度も言われていますが、Wrangler はプロジェクトごとに入れましょう。 *1
$ npm install --save-dev wrangler@latest
公式ドキュメントによると以下のように書かれているため、念の為Wranglerのバージョンを確認しておきます。
You must use Wrangler version 3.99.0 or later to deploy Next.js apps using @opennextjs/cloudflare.
$ ./node_modules/wrangler/bin/wrangler.js --version
⛅️ wrangler 4.16.1
-------------------
問題なさそうです。
3. Config
wrangler と open-next のコンフィグの用意は特にしなくていいです。
コンフィグがない場合は、ビルド時に@opennextjs/cloudflareが勝手に作成してくれますし、wrangler については雛形を作成した時点でいい感じの wrangler.jsonc を用意してくれます。
4. package.json にいろいろ書く
pakcage.json の script に以下の内容を追加しておきます。
"preview": "opennextjs-cloudflare build && opennextjs-cloudflare preview", "deploy": "opennextjs-cloudflare build && opennextjs-cloudflare deploy", "upload": "opennextjs-cloudflare build && opennextjs-cloudflare upload", "cf-typegen": "wrangler types --env-interface CloudflareEnv cloudflare-env.d.ts",
4. 静的コンテンツのキャッシュ設定
public/_headersファイルを作成して、以下の内容を書いておきましょう。
/_next/static/* Cache-Control: public,max-age=31536000,immutable
Next.js が生成する不変の static データをいい感じにキャッシュしておくためにこれを明示しておく必要があります。 Workers で動かす都合で、next.config.tsで静的ファイル向けのCache-Control ヘッダを書き換えてもうまく動作しません。
5. 確認とデプロイ
これで置き換えが完了したはずなので、うまくできているかローカルで起動チェックぐらいはしておくと良いでしょう。
ついでにデプロイしてみましょう。前に package.jsonを書き換えているのでデプロイするときは npm run deploy を実行するだけで良いです。便利。
ちなみにデプロイした時に以下のようにコードサイズを教えてくれます。
Total Upload: 13972.53 KiB / gzip: 2320.56 KiB
Workers の無料枠は上限 3MiB ですが、もうこの時点ですでに 2.3MiB ぐらいあるので Workers の無料枠にすぐに当たりそうです。
Hono をマウントする
Hono を Next.js に載せる方法や、そのほかの手法については以下の資料が参考になります。
ここでやることは @yusukebe さんのこのスライドのままです。
Install Hono
$ npm i hono added 1 package, and audited 1094 packages in 1s 173 packages are looking for funding run `npm fund` for details found 0 vulnerabilities
Catch-all Route を /api に作る
src/app/api/[[...route]]/route.ts に以下の内容を書きます。
import { Hono } from "hono"; const app = new Hono().basePath("/api"); app.get("/", (c) => c.text("API Route /")); app.get("/hello", (c) => c.text("API Route /hello")); app.get("/hello/:name", (c) => c.text(`API Route /hello/${c.req.param("name")}`)); export const GET = app.fetch; export const POST = app.fetch; export type HonoAppType = typeof app;
これでヨシ!かと思いきや、Hono Client を使った RPC の恩恵を受けるためにはこの書き方ではあまりよろしくないのでいい感じに書き直します。
import { Hono } from "hono"; const app = new Hono().basePath("/api") .get("/", (c) => c.text("API Route /")) .get("/hello", (c) => c.text("API Route /hello")) .get("/hello/:name", (c) => c.text(`API Route /hello/${c.req.param("name")}`)); export const GET = app.fetch; export const POST = app.fetch; export type HonoAppType = typeof app;
メソッドチェーンとして書き直しました。これでパーペキです。
デプロイしてみる
デプロイはnpm run deployです。かんたん。
$ curl -s "https://***.workers.dev/api/hello/sample" API Route /hello/sample
動いてそうなのでヨシ。
Cloudflare D1 と繋げてみる
大体やることは公式ドキュメントに書いてあるので一度見ておくと不幸にならないと思います。
D1 Database の作成
wrangler d1 createでDBの作成ができます。
$ npx wrangler d1 create cfnext
⛅️ wrangler 4.16.1
-------------------
✅ Successfully created DB 'cfnext' in region APAC
Created your new D1 database.
{
"d1_databases": [
{
"binding": "DB",
"database_name": "cfnext",
"database_id": "****************************"
}
]
}
出力されている JSON 部分を wrangler.jsonc に書いておきます。
ついでにここで型定義の更新をしておくと良さそうです。
型定義の更新は、前に定義したnpm run cf-typegenで実行できます。
$ npm run cf-typegen
> cfnext@0.1.0 cf-typegen
> wrangler types --env-interface CloudflareEnv ./cloudflare-env.d.ts
⛅️ wrangler 4.16.1
-------------------
Generating project types...
declare namespace Cloudflare {
interface Env {
NEXTJS_ENV: string;
DB: D1Database;
ASSETS: Fetcher;
}
}
interface CloudflareEnv extends Cloudflare.Env {}
Generating runtime types...
Runtime types generated.
────────────────────────────────────────────────────────────
✨ Types written to ./cloudflare-env.d.ts
📖 Read about runtime types
https://developers.cloudflare.com/workers/languages/typescript/#generate-types
📣 Remember to rerun 'wrangler types' after you change your wrangler.json file.
いい感じに更新されてそうですね。
Schema の定義
db/schema.sql にいい感じのテーブルを書いておきます。
CREATE TABLE IF NOT EXISTS articles ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, content TEXT NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP );
次にnpx wrangler d1 execute cfnext --local --file=./db/schema.sqlを実行してローカルのDBに向けてクエリを発行します。
$ npx wrangler d1 execute cfnext --local --file=./db/schema.sql ⛅️ wrangler 4.16.1 ------------------- 🌀 Executing on local database cfnext (***) from .wrangler/state/v3/d1: 🌀 To execute on your remote database, add a --remote flag to your wrangler command. 🚣 1 command executed successfully.
うまくいってそう。ダミーのデータも入れてみます。
INSERT INTO articles (title, content) VALUES ('Hello World', 'This is a test article'); INSERT INTO articles (title, content) VALUES ('Hello World 2', 'This is a test article 2'); INSERT INTO articles (title, content) VALUES ('Hello World 3', 'This is a test article 3');
db/seed.sqlに上記の内容を書いてnpx wrangler d1 execute cfnext --local --file=./db/seed.sql を実行します。これでよし。
中身が入っているかクエリしてみると、うまくデータが入ってそうなのでヨシ。
$ npx wrangler d1 execute cfnext --local --command="SELECT * FROM articles" ⛅️ wrangler 4.16.1 ------------------- 🌀 Executing on local database cfnext (***) from .wrangler/state/v3/d1: 🌀 To execute on your remote database, add a --remote flag to your wrangler command. 🚣 1 command executed successfully. ┌────┬───────────────┬──────────────────────────┬─────────────────────┐ │ id │ title │ content │ created_at │ ├────┼───────────────┼──────────────────────────┼─────────────────────┤ │ 1 │ Hello World │ This is a test article │ 2025-05-24 12:28:01 │ ├────┼───────────────┼──────────────────────────┼─────────────────────┤ │ 2 │ Hello World 2 │ This is a test article 2 │ 2025-05-24 12:28:01 │ ├────┼───────────────┼──────────────────────────┼─────────────────────┤ │ 3 │ Hello World 3 │ This is a test article 3 │ 2025-05-24 12:28:01 │ └────┴───────────────┴──────────────────────────┴─────────────────────┘
Hono 側で扱う
通常の Hono なら以下のように書いてやると c.env.DB からデータが取れますが、今回のケースでは動きません。
// 普通にやるならこんな感じでいけるが、今回は動かない。
type Bindings = {
DB: D1Database
}
const app = new Hono<{ Bindings: Bindings }>().basePath("/api");
OpenNext ので動かす場合は、getCloudflareContext() を使いましょう。
実際にやってみるとこんな感じで書きます。
app.post("/articles", async (c) => { const {title, content} = await c.req.json(); const ctx = getCloudflareContext(); const res = await ctx.env.DB.prepare("INSERT INTO articles (title, content) VALUES (?, ?)").bind(title, content).run(); return c.json(res); });
実際にリクエストを送って確認します。
$ curl -X POST "http://localhost:3000/api/articles" -H "Content-Type: application/json" -d '{ "title": "sample", "content":"content body" }'
余談ですが、 curl には--jsonオプションがあるんですが律儀に書いてから思い出すという愚行をしています。
$ curl -s "http://localhost:3000/api/articles" | jq
[
{
"id": 1,
"title": "Hello World",
"content": "This is a test article",
"created_at": "2025-05-24 12:28:01"
},
{
"id": 2,
"title": "Hello World 2",
"content": "This is a test article 2",
"created_at": "2025-05-24 12:28:01"
},
{
"id": 3,
"title": "Hello World 3",
"content": "This is a test article 3",
"created_at": "2025-05-24 12:28:01"
},
{
"id": 4,
"title": "sample",
"content": "content body",
"created_at": "2025-05-24 12:44:57"
}
]
期待通りですね。
デプロイする
デプロイする前に一度本番DBに対してもマイグレーションを実行しておきましょう。
$ npx wrangler d1 execute cfnext --remote --file=./db/schema.sql ⛅️ wrangler 4.16.1 ------------------- ✔ ⚠️ This process may take some time, during which your D1 database will be unavailable to serve queries. Ok to proceed? … yes 🌀 Executing on remote database cfnext (***): 🌀 To execute on your local development database, remove the --remote flag from your wrangler command. Note: if the execution fails to complete, your DB will return to its original state and you can safely retry. ├ 🌀 Uploading ***.sql │ 🌀 Uploading complete. │ 🌀 Starting import... 🌀 Processed 1 queries. 🚣 Executed 1 queries in 0.00 seconds (2 rows read, 4 rows written) Database is currently at bookmark ***. ┌────────────────────────┬───────────┬──────────────┬────────────────────┐ │ Total queries executed │ Rows read │ Rows written │ Database size (MB) │ ├────────────────────────┼───────────┼──────────────┼────────────────────┤ │ 1 │ 2 │ 4 │ 0.02 │ └────────────────────────┴───────────┴──────────────┴────────────────────┘
はい良さそうですね。あとはいつものnpm run deployでデプロイしてフィニッシュ。
Hono Client から API を叩いてみる
Hono には RPC の機能があるので、せっかくなら試してみましょう。
クライアントの準備
CSRする時は以下のようなクライアントを作っておきます。
import { hc } from "hono/client"; import { HonoAppType } from "./route"; export const apiClient = hc<HonoAppType>("/");
一方で SSR 向けには以下のようなクライアントを作っておきます。
'use server' import { hc } from "hono/client"; import { HonoAppType } from "@/app/api/[[...route]]/route"; import { headers } from "next/headers"; export const ssrClient = async () => { const headersList = await headers() const host = headersList.get('host')?.replace(/\/$/, '') ?? 'localhost:3000' const scheme = host?.includes('localhost') ? 'http' : 'https' return hc<HonoAppType>(`${scheme}://${host}/`) }
あとはこれを import して呼び出してあげましょう。
呼び出し
CSR なページならこんな感じにかけます。
'use client' import { Article } from "@/types"; import { useEffect, useState, useCallback } from "react"; import { apiClient } from "../api/[[...route]]/client"; export function useArticles() { const [articles, setArticles] = useState<Article[]>([]); const [loading, setLoading] = useState(true); const [error, setError] = useState<string | null>(null); const fetchArticles = useCallback(async () => { setLoading(true); setError(null); try { // apiClient から GET /api/articles ができる。しかも型情報のおまけつき! const res = await apiClient.api.articles.$get(); const data = await res.json(); setArticles(data); } catch (e) { setError(e instanceof Error ? e.message : "An unknown error occurred"); } finally { setLoading(false); } }, []); useEffect(() => { fetchArticles(); }, [fetchArticles]); return { articles, loading, error, refetch: fetchArticles }; } export default function ArticlesPage() { const { articles, loading, error } = useArticles(); return ( <div> <h1>Articles</h1> {loading && <div>Loading...</div>} {error && <div>Error: {error}</div>} <div> {articles.map((article) => ( <div key={article.id}> <div>{article.title}</div> <div>{article.content}</div> </div> ))} </div> </div> ); }
SSRなページならこんな感じです。
import { ssrClient } from "@/client";
export default async function ArticlesSSRPage() {
const cli = await ssrClient();
const res = await cli.api.articles.$get();
const articles = await res.json();
return <div>
<h1>Articles</h1>
<div>
{articles.map((article) => (
<div key={article.id}>
<div>{article.title}</div>
<div>{article.content}</div>
</div>
))}
</div>
</div>;
}
便利ですね。
感想
本当はこの後に Drizzle と繋げてみたり、認証ライブラリを入れてみたりとモダン人間になるために素振りを重ねていたのですが、それをここにまとめると思っていたより長くなったので別の記事に書きます。
ここまでの体験で詰まるところがほとんどなくて開発者体験としてめちゃくちゃ良かったのが印象的でした。 プロジェクトのセットアップから、OpenNext への載せ替えと Hono のマウントまで本当にシームレスでした。
個人的には API Route に Hono を載せられるのはかなり魅力的だなと感じていて、というのも Hono を使うことで Hono 自身がバックエンドとフロントエンドの開発上の分界点として機能するからです。 アーキテクチャの都合やその他の制約で Next.js で API を書くのは辛さがある感じがしますが、まずこれが Hono で解消されます。当然 Middleware の部分もそうです。Next.js やそのエコシステムの恩恵を受けつつもバックエンドとしても応用できる状態で、それぞれがいい感じに共生しているような状態とも言える気がします。
何より RPC の機能がインタフェースとしてかなり強力で良かったです。今回はそのまま全部のルートを export していましたが、実際にやるなら API として使うところだけ export しておくといった使い方になりそうです。
やや DB などの Binding 周りで一工夫必要にはなりますが、個人開発でやるなら基本的にこのスタックにしておけば困らないんじゃないかというレベルには手に馴染む感じだったのでしばらく色々遊んでみようと思います。