ハンドルネームの敬称は省略できます

🦀パソコンを叩く日々🐈

OpenNext の App Router に Hono を載せて Cloudflare Workers にデプロイする。ついでに D1 を添えて

こんにちは。 id:rokuokun です。 n 番煎じ感はありますが休日に表題のことをやりました。 その時のメモにいい感じの説明を付け加えて記事らしい感じに仕立てたものを残します。

プロジェクト作成

お馴染みのコマンドでCloudflare Workers で Next.js を動かす雛形を作ってもらいます。

このコマンドを実行すると最後にWorkersへデプロイするかどうか尋ねられますがお好みでどうぞ。 僕はこの後にOpenNextでちゃんとデプロイできるか試したかったのでそのままデプロイしました。当たり前ですが、これでデプロイされるのは普通の Next.js です。

$ npm create cloudflare@latest -- --framework=next --platform=workers

OpenNext に置き換える

やることは公式ドキュメントに懇切丁寧に書かれています。 この記事では自分がやったことのみを記載します。

opennext.js.org

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 ヘッダを書き換えてもうまく動作しません。

opennext.js.org

5. 確認とデプロイ

これで置き換えが完了したはずなので、うまくできているかローカルで起動チェックぐらいはしておくと良いでしょう。

ついでにデプロイしてみましょう。前に package.jsonを書き換えているのでデプロイするときは npm run deploy を実行するだけで良いです。便利。

ちなみにデプロイした時に以下のようにコードサイズを教えてくれます。

Total Upload: 13972.53 KiB / gzip: 2320.56 KiB

Workers の無料枠は上限 3MiB ですが、もうこの時点ですでに 2.3MiB ぐらいあるので Workers の無料枠にすぐに当たりそうです。

developers.cloudflare.com

Hono をマウントする

github.com

Hono を Next.js に載せる方法や、そのほかの手法については以下の資料が参考になります。

blog.stin.ink

ここでやることは @yusukebe さんのこのスライドのままです。

Install Hono

hono.dev

$ 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 と繋げてみる

大体やることは公式ドキュメントに書いてあるので一度見ておくと不幸にならないと思います。

developers.cloudflare.com

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.js.org

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 の機能があるので、せっかくなら試してみましょう。

hono.dev

クライアントの準備

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 周りで一工夫必要にはなりますが、個人開発でやるなら基本的にこのスタックにしておけば困らないんじゃないかというレベルには手に馴染む感じだったのでしばらく色々遊んでみようと思います。