Next.js × Honoプロジェクトをturborepoで作成する

このページを参考に構築していく。pnpm
を使用。
とりあえず、turbo
をグローバルインストールした方がいいみたいなので、以下コマンドでインストール
pnpm install turbo --global
以下のexampleから始める。
Next.js(フロント) × Hono(バック) × 共通モジュールとするため、なんとなくkitchen-sinkを選択。名前はexample-turborepoで、パッケージマネージャーはpnpmを使用。
? Where would you like to create your Turborepo? example-turborepo
? Which package manager do you want to use? pnpm
すると、プロジェクトの作成が始まる。
>>> Creating a new Turborepo with:
Application packages
- apps/admin
- apps/api
- apps/blog
- apps/storefront
Library packages
- packages/config-eslint
- packages/config-typescript
- packages/jest-presets
- packages/logger
- packages/ui
>>> Success! Created your Turborepo at example-turborepo
To get started:
- Change to the directory: cd example-turborepo
- Enable Remote Caching (recommended): pnpm dlx turbo login
- Learn more: https://turborepo.com/remote-cache
- Run commands with Turborepo:
- pnpm run build: Build all apps and packages
- pnpm run dev: Develop all apps and packages
- pnpm run lint: Lint all apps and packages
- pnpm run test: Test all apps and packages
- Run a command twice to hit cache
次のページは既存リポジトリへの追加だった。ここからは別のドキュメントを参考にしながら進めていく。

admin/[...]/main.tsxの内容がこれだった。
ぱっと見フロントからバックへのアクセスはしてなさそうかも。
import "./styles.css";
import { CounterButton } from "@repo/ui/counter-button";
import { Link } from "@repo/ui/link";
function App() {
return (
<div className="container">
<h1 className="title">
Admin <br />
<span>Kitchen Sink</span>
</h1>
<CounterButton />
<p className="description">
Built With{" "}
<Link href="https://turborepo.com" newTab>
Turborepo
</Link>
{" & "}
<Link href="https://vitejs.dev/" newTab>
Vite
</Link>
</p>
</div>
);
}
export default App;
でも、一旦このリポジトリの内容を解析してみる。

ルートのturbo.json
を見ていく。
$schema
"$schema": "https://turborepo.com/schema.json"
turbo.json
のスキーマを指定するためのものらしい。tasks
を削除したら警告が出た。
task
dependsOn
から見ていく。
dependsOn
では、タスク実行前に完了している必要のあるタスクを指定する。
対象のパッケージが他のパッケージに依存している場合、どのような順序でタスクを実行していくべきかを指定する。
そして、dependsOn
には依存関係、同一パッケージ関係、任意のタスク関係の3種類があ離、^
や#
で表現する。
依存関係
依存している他のパッケージの指定したタスクが完了するまで実行を待つ。turbo
は、内部依存関係のないパッケージが見つかるまで再起的にパッケージを探す。
以下のような依存関係がある場合。
web -> ui -> utils
web
はui
に依存しているため、ui
のビルドを待つ。ui
はutils
に依存しているため、utils
のビルドを待つ。utils
は他に依存関係がないため、ビルドが実行される。ビルドが完了次第、次のビルドが実行される。
これを表現するのが^
であり、以下のコードは「ビルド時には依存関係のビルドが完了するまで実行を待つ」ことを表現している。
{
"tasks": {
"build": {
"dependsOn": ["^build"]
}
}
}
同一パッケージ関係
同一パッケージの他タスクが完了するのを待つ。^
を使用せずに記述することで同一パッケージ関係を表現する。以下のコードは、「テスト時には同一パッケージのリントとビルドが完了するまで実行を待つ」ことを表現している。
{
"tasks": {
"test": {
"dependsOn": ["lint", "build"]
}
}
}
任意のタスク関係
依存しているすべてのパッケージや、自分自身の他タスクではなく、特定のパッケージのタスクの完了をまつ。依存パッケージ#タスク名
で表現する。
以下のコードは、「webのリント時にはutilsのビルドが完了するまで実行を待つ」ことを表現している。
{
"tasks": {
"web#lint": {
"dependsOn": ["utils#build"]
}
}
}

inputs
パッケージが変更されたかどうかを判断するためのファイルを指定する。
何も指定していなければソース管理にチェックインされているファイルを参照する。(.gitignore
に含まれていないファイル)
inputs
を指定し、変更を判断するファイルを自分で記述した場合、上記のデフォルト動作が機能しなくなり、指定したファイルのみで変更を判断するようになる。ただし、turbo.json
の変更は必ず検知される。
デフォルトの動作を保ったまま対象ファイルを追加したり特定のファイルのみは参照しないようにするには、$TURBO_DEFAULT$
を使用する。
以下のコードは、「デフォルトの動作はそのままに、README.mdの変更は検知しない」ことを表現している。
{
"tasks": {
"check-types": {
"inputs": ["$TURBO_DEFAULT$", "!README.md"]
}
}
}
またパッケージ内ではなく、リポジトリのルートディレクトリから参照したい場合には、$TURBO_ROOT$
を使用する。
以下のコードは、「ルートディレクトリに存在するtsconfig.json
と、パッケージ内のsrc以下のtsファイルの変更を検知する」ことを表現している。
{
"tasks": {
"check-types": {
// Consider all Typescript files in `src/` and the root tsconfig.json as inputs
"inputs": ["$TURBO_ROOT$/tsconfig.json", "src/**/*.ts"]
}
}
}

outputs
タスクが正常終了した時にキャッシュされる成果物を指定する。
inputs
で指定したファイルに変更がないときには、outputs
の中身をそのまま成果物として扱う。変更されたパッケージのタスクのみを再実行し、無駄なタスクの再実行を防ぐことができる。
以下のコードは、「dist
以下の成果物はinputs
に変更がない限り前回の結果が使用される」ことを表現している。
{
"tasks": {
"build": {
"outputs": ["dist/**"]
}
}
}

cacheとpersistent
dev
などはキャッシュされてはならない(キャッシュヒットしたら開発サーバーが建たないは流石に…)
そのため、cache
をfalse
に設定する。また、persistent
をtrue
にすることで、他のタスクから依存されないようにする。(設定ミスでtest
、build
などがdev
に依存してしまい、タスクが完了しないという事態を避けるため)

ここまで読んでいくと、サンプルのturbo.json
を見てもわかりそう。(uiは忘れてたけど、tuiでは各ログを一度に表示し、操作可能。streamではすぐに出力され、対話型ではない。)
{
"$schema": "https://turborepo.com/schema.json",
"ui": "tui",
"tasks": {
"build": {
"inputs": ["$TURBO_DEFAULT$", ".env*"],
"dependsOn": ["^build"],
"outputs": [
"build/**",
".vercel/**",
"dist/**",
".next/**",
"!.next/cache/**"
]
},
"test": {
"outputs": ["coverage/**"],
"dependsOn": []
},
"lint": {
"dependsOn": ["^build", "^lint"]
},
"check-types": {
"dependsOn": ["^build", "^check-types"]
},
"dev": {
"dependsOn": ["^build"],
"cache": false,
"persistent": true
}
}
}
build
-
.gitignore
に追加されていないファイルと.env*
ファイルが変更されている場合、ビルドを実行する。 - 自分が依存しているパッケージのビルドが完了するまで待機する。
- 以下のフォルダをキャッシュする
- build
- vercel
- dist
- .next
- 以下のフォルダをキャッシュの対象から外す。
- .next/cache
test
-
.gitignore
に追加されていないファイルが変更されている場合、テストを実行する。 - 依存関係はなく、テストカバレッジ(
coverage/**
)をキャッシュする。
lint
-
.gitignore
に追加されていないファイルが変更されている場合、リントを実行する。 - 自分が依存しているパッケージのビルドとリントが完了するまで待機する。
check-types
-
.gitignore
に追加されていないファイルが変更されている場合、型チェック(tsc --noEmit
)を実行する。 - 自分が依存しているパッケージのビルドと型チェックが完了するまで待機する。
dev
-
.gitignore
に追加されていないファイルが変更されている場合、型チェック(tsc --noEmit
)を実行する。 - 自分が依存しているパッケージのビルドが完了するまで待機する。
- キャッシュを無効化し、前回から変更がなくとも開発サーバーが立ち上がるようにする。
- 他のタスクから依存されないようにする。

Next.js × Honoを構築してみる
Honoのrpcを使用したいため、依存の向きとしてはこのようになる。
next -> hono
以下に従って最初からプロジェクトを作成する。
その後、appsの中にnext/
とhono/
としてプロジェクトを作成する。Next.jsはOpenNextを使用する。

簡単にコードを書いてみた。
- APIエンドポイントといってもほとんど初期のままで、
const app = new Hono() .get('/', (c) => { return c.text('Hello Hono!') }) export default app export type AppType = typeof app
app.get
をnew Hono().get
にすることで、型情報が失われることを防ぐ程度。あとは、AppTypeをexportすることで、Honoのエンドポイントを型安全に呼び出す準備を行う。 - Hono Clientフロントエンドの環境変数によってAPIのエンドポイントを変更したいため、urlを受け取りクライアントを返す関数を作成。フロント側にHonoを入れたくないため、この関数はHono側に実装。
export const getClient = (url: string = "http://localhost:53925") => { return hc<AppType>(url) }
- Next.jsHonoのクライアントを使用してリクエストを送信するだけの簡単なコード。
'use client' import { getClient } from "@hono/client" import { useEffect, useState } from "react" export default function Home() { const [message, setMessage] = useState("") const client = getClient(process.env.NEXT_PUBLIC_API_URL) useEffect(() => { (async () => { const message = await (await client.index.$get()).text() setMessage(message) })() }, [client]) return ( <p> {message} </p> ) }
- tsconfigHonoのクライアントなどを@honoで利用できるようにtsconfigを修正。
"@hono/*": ["../hono/src/*"]

【ちょっとした気づき集】
- OpenNextでデプロイしているNext.jsアプリのクライアントサイドで環境変数にアクセスするにはデプロイ時に
.env
ファイルにNEXT_PUBLIC
プレフィクスをつけた環境変数を書いておく必要があるらしい。.envと.dev.vars
ここはNext.jsの仕様なのかな?どうも
.env
と.dev.vars
あたりがよくわからない。サーバーサイドで使いたいものは.dev.varsに書いて、クライアントで使いたいものはNEXT_PUBLIC
付きで.envにおけばいいのかな?ただ、ダッシュボードでクライアントサイドでどんな環境変数を使ってるのかがわからないのが難点。そう考えるといっそ.env
もNEXT_PUBLIC前提でリポジトリに含めちゃってもいいのかな - HonoのCORS設定では関数を渡すことができる。そのため、自動生成されるURLがCORSで弾かれないようにするためにはこんな感じの設定ができる。
.use("*", cors({ origin: origin => { if(origin.includes('localhost')) return origin if(origin.endsWith("-app.your-subdomain.workers.dev")) return origin return null } }))

これ、先頭に-
つけるとworkers dev URLはCORSで弾かれるから消した方がいいかも。

とりあえずごく簡単に。
ルートのpackage.json
とturbo.json
にデプロイ関係の記述を追加。
"deploy": "turbo run deploy",
"deploy": {
"dependsOn": ["^deploy"]
}
これでキャッシュが効くことも確認した。パフォーマンスは一旦置いておくにしても、もうこれで動かせるのか??
次はGitHub Actionsを使ったCI/CDの構築にチャレンジかな。

こんな感じにしてみた。Cloudflareへのデプロイではcloudflare/wrangler-action
が使われがちだが、今回はturborepo
の機能を使ってキャッシュを活かしたかったため、pnpm run deploy
でデプロイするようにした。今まで知らなかったけど、環境変数でCLOUDFLARE_API_TOKEN
を渡しておけばwrangler deploy
でもしっかりデプロイできるみたい。
name: Deploy
on:
push:
branches-ignore: ["main"]
pull_request:
types: [opened, synchronize]
jobs:
deploy:
name: Deploy
timeout-minutes: 15
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [22]
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
steps:
- name: Check out code
uses: actions/checkout@v4
with:
fetch-depth: 2
- name: Install pnpm
uses: pnpm/action-setup@v3
with:
version: 10
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'pnpm'
- name: Install dependencies
run: pnpm install
- name: Lint
run: pnpm lint
- name: Deploy
run: pnpm run deploy
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
この時には、turbo.json
でenv
を指定する必要がある。
"deploy": {
"dependsOn": [
"^deploy"
],
"env": [
"CLOUDFLARE_API_TOKEN"
]
}

GitHub Actionsを使ってデプロイする時には、.envファイルがないからNEXT_PUBLIC_API_URL
が存在しないんだ。
NEXT_PUBLIC
系はビルド時に必要だから、envで渡してあげる必要がある。
- name: Deploy
run: pnpm run deploy
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
NEXT_PUBLIC_API_URL: ${{ vars.NEXT_PUBLIC_API_URL }}