Next.js プロジェクトを Bun へ完全移行実録:もう Node.js に戻れない!
はじめに:ロードマップを現実に
前回の記事で紹介した「Bun の 4 つの顔」と「段階的導入ロードマップ」、覚えていますか?「速いのはわかったけど、既存の Next.js プロジェクトに導入したらどうなるの?」という疑問を持った方も多いはず。
前回の記事はこちら:
そこで今回は、私の Next.js 16 デモプロジェクトである my-blog-app を実験台に、そのロードマップを実際に駆け抜けてみました。pnpm + Node.js という王道構成から、Bun への完全移行します。
先に結論を言いましょう。「もう、Node.js には戻れません。」
26 秒で終わる CI、瞬時に完了するテスト……その爆速の世界を、実録形式でお届けします!
事前準備:Bun のインストール(OS 別)& TypeScript 設定
この記事は「既存の Next.js(TypeScript)プロジェクトを Bun へ移行する」実録なので、まずは Bun 本体のインストールと、TS プロジェクトならではの下ごしらえ(型定義 / tsconfig)を押さえておきます。
1) Bun のインストール(macOS / Linux)
公式のインストールスクリプトで導入できます。
curl -fsSL https://bun.sh/install | bash
インストール後、bun が見つからない場合は PATH を追加します(公式案内)。
export BUN_INSTALL="$HOME/.bun"
export PATH="$BUN_INSTALL/bin:$PATH"
Linux の場合は
unzipが必要です。またカーネルは 5.6+ 推奨(最低 5.1)なので、古い環境だと動かない場合があります(公式案内)。
2) Bun のインストール(Windows / PowerShell)
Windows は PowerShell で公式スクリプトを実行します(Windows 10 1809+ が必要)。
powershell -c "irm bun.sh/install.ps1|iex"
インストール直後に bun コマンドが認識されない場合は、まず次で実行できるか確認できます(公式案内)。
& "$env:USERPROFILE\.bun\bin\bun" --version
PATH へ追加したい場合は、以下の公式スニペットでユーザー環境変数に追記します(その後ターミナル再起動)。
[System.Environment]::SetEnvironmentVariable(
"Path",
[System.Environment]::GetEnvironmentVariable("Path", "User") + ";$env:USERPROFILE\.bun\bin",
[System.EnvironmentVariableTarget]::User
)
3) インストール確認(全 OS 共通)
bun --version
bun --revision
4) アップグレード / アンインストール(必要なときだけ)
-
アップグレード:
bun upgrade(※ Homebrew / Scoop で入れた場合は、それぞれbrew upgrade bun/scoop update bun推奨) -
アンインストール:
- macOS / Linux:
rm -rf ~/.bun - Windows:
powershell -c ~\.bun\uninstall.ps1
- macOS / Linux:
ここまでが「Bun 本体の準備」です。次に TypeScript の下ごしらえへ進みます。
5) TypeScript プロジェクトの下ごしらえ(@types/bun & compilerOptions)
TypeScript で Bun グローバル(例:typeof Bun !== "undefined")や Bun の組み込み API を使うなら、型定義を入れておくのが必須です。
bun add -d @types/bun
tsconfig.json(Bun 推奨の compilerOptions)
Bun は「top-level await」「JSX」「.ts 拡張子付き import」などをサポートしますが、TypeScript 側の設定が追いついていないと警告が出たり、補完が効かなかったりします。公式ドキュメントでは、以下のような compilerOptions を推奨しています。
{
"compilerOptions": {
// 環境設定と最新の機能
"lib": ["ESNext"],
"target": "ESNext",
"module": "Preserve",
"moduleDetection": "force",
"jsx": "react-jsx",
"allowJs": true,
// バンドラーモードの設定
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
// ベストプラクティス
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
// より厳密なフラグ(デフォルトでは無効)
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false
}
}
補足(Next.js との関係): Next.js プロジェクトでは
tsconfig.jsonに Next 用の設定(plugins: [{ "name": "next" }]など)が入ります。上の “公式推奨” を丸ごとコピペするというより、moduleResolution: "bundler"を中心に、必要な項目だけ取り込むのが安全です(本記事のmy-blog-appもその方針です)。
Step 1: パッケージマネージャの切り替え(Level 1)
まずは基本中の基本、パッケージマネージャの変更です。ここでは、過去の遺産を捨てる「断捨離」の儀式が必要になります。
1. 既存のロックファイルを削除
お使いの環境に合わせて、プロジェクトのルートディレクトリにある以下のファイルを迷わず削除してください。
-
npm の場合:
package-lock.json -
yarn の場合:
yarn.lock -
pnpm の場合:
pnpm-lock.yaml(私の環境はこれでした)
2. bun install の実行
そして、ターミナルで以下の魔法を唱えます。
bun install
「え、もう終わったの?」
あまりの速さに、最初はコマンドが失敗したのかと疑うレベルです。これにより、新時代のロックファイル bun.lock が生成されます(以下の画像参照)。

豆知識:
bun.lockはテキスト形式です。バイナリだった以前のbun.lockbと違い、Git で普通に diff が読めます。コードレビューで「依存関係、何が変わったんだ……?」と悩む日々とはおさらばです!
Step 2: テストランナー・スクリプト実行としての活用(Level 2 & 3)
次に、開発中の強力な味方「テスト」を Bun に任せてみます。
1. テスト構成(ディレクトリ全体像)
まず、テストはこういう構造になっています。
tests/
├── setup.ts # happy-dom をグローバル登録
├── unit/ # ユニットテスト(純関数・ロジック)
│ └── lib/
│ └── posts.test.ts
├── components/ # コンポーネントテスト
│ └── like-button.test.tsx
└── integration/ # 統合テスト(将来拡張枠)
「ロジック」と「UI」を分けておくと、テストが増えても整理しやすいのでおすすめです。
2. Happy DOM と preload 設定(bunfig.toml)
React コンポーネントのテストでは DOM が必要です。Bun のテスト環境は軽量な Happy DOM を使えるのが強みで、JSDOM より起動が軽い(体感が速い)です。
このプロジェクトでは bunfig.toml で preload を設定し、毎回手動で setup を import しなくていい形にしています。
[test]
preload = ["./tests/setup.ts"]
preload される tests/setup.ts はこれです(happy-dom のグローバルを登録)。
import { GlobalRegistrator } from "@happy-dom/global-registrator";
// テスト実行前に happy-dom のグローバルを登録
GlobalRegistrator.register();
my-blog-app では、Bun の実力を測るために 2 つの典型的なテストを用意しています。
-
Unit Test (
tests/unit/lib/posts.test.ts): 記事データのソートやフィルタリングといった純粋なロジックを検証。 -
Component Test (
tests/components/like-button.test.tsx): Happy DOM を使い、「いいねボタン」をクリックした時の状態変化をシミュレーション。
※これらはあくまで Bun のテストランナーとしての実力を検証するための代表例です。実際のプロジェクトではもっと多くのテストを書きますが、Bun の速さを実感するには十分です!
3. package.json の修正(テスト関連コマンド)
テスト関連のスクリプトを、Bun ネイティブのものに書き換えます。
"scripts": {
// ...他のコマンド
"test": "bun test tests/", // テストはBunネイティブ!
"test:watch": "bun test tests/ --watch" // 爆速ウォッチモード
}
ポイントはこの 2 つです。
-
bun:testは Bun 内蔵なので、Jest みたいに「ランナー起動 → 環境構築 → 実行」のオーバーヘッドが小さい -
--watchの「保存 → 即実行 → 即結果」のテンポが、TDD(Test Driven Development,テスト駆動開発)の気持ちよさを上げてくれる
4. Unit Test(posts.test.ts)全文
ロジックテストは短く・読みやすく・速いのが正義。今回は全文をそのまま載せます。興味のある方は、下記をクリックして実際のコードを覗いてみてください!
posts.test.ts
import { expect, test, describe } from "bun:test";
import { getPosts, getPost } from "@/app/lib/posts";
describe("posts library", () => {
test("getPosts returns all posts", async () => {
const posts = await getPosts();
expect(posts).toBeArray();
expect(posts.length).toBeGreaterThan(0);
expect(posts[0]).toHaveProperty("id");
expect(posts[0]).toHaveProperty("title");
});
test("getPost returns a specific post by slug", async () => {
const slug = "nextjs-1";
const post = await getPost(slug);
expect(post).toBeDefined();
expect(post?.id).toBe(slug);
expect(post?.title).toContain("Next.js 16 入門 ①");
});
test("getPost returns undefined for non-existent slug", async () => {
const post = await getPost("non-existent");
expect(post).toBeUndefined();
});
});
5. Component Test(like-button.test.tsx)抜粋(全テストケースを掲載)
こちらはコード量が多いので、各テストケースは載せつつ、長い部分だけ ... で省略します。興味のある方は、下記をクリックして実際のコードを覗いてみてください!
like-button.test.tsx
describe("LikeButton Component", () => {
const mockPostId = "test-post-1";
const mockInitialLikes = 10;
...
test("renders with initial likes count", () => {
...
expect(likeButton).toBeDefined();
expect(likeButton.textContent).toContain("10");
});
test("increments likes when clicked", async () => {
...
expect(likeButton.textContent).toContain("10");
await user.click(likeButton);
await waitFor(() => {
expect(likeButton.textContent).toContain("11");
});
});
test("disables button and shows 'Thanks!' after click", async () => {
...
await user.click(likeButton);
await waitFor(() => {
expect(likeButton).toHaveProperty("disabled", true);
expect(screen.getByText("Thanks!")).toBeDefined();
});
});
test("saves and loads likes from localStorage", async () => {
...
await user.click(likeButton);
await waitFor(() => {
expect(likeButton.textContent).toContain("11");
});
// localStorage が更新されることを確認
const storedLikes = localStorage.getItem(`likes-${mockPostId}`);
expect(storedLikes).toBe("11");
// 再マウントして localStorage から復元されることを確認
unmount();
render(<LikeButton postId={mockPostId} initialLikes={mockInitialLikes} />);
await waitFor(() => {
const newButton = screen.getByRole("button");
expect(newButton.textContent).toContain("11");
expect(newButton).toHaveProperty("disabled", true);
expect(screen.getByText("Thanks!")).toBeDefined();
});
});
test("prevents duplicate likes from the same user", async () => {
...
await user.click(likeButton);
await waitFor(() => {
expect(likeButton.textContent).toContain("11");
expect(likeButton).toHaveProperty("disabled", true);
});
// 無効化されているので再クリックしても変化しない
await user.click(likeButton);
await waitFor(() => {
expect(likeButton.textContent).toContain("11");
});
});
test("handles multiple different posts independently", async () => {
...
await user.click(button1);
await waitFor(() => {
expect(button1.textContent).toContain("6");
});
expect(localStorage.getItem(`likes-${post1}`)).toBe("6");
unmount();
render(<LikeButton postId={post2} initialLikes={20} />);
const button2 = screen.getByRole("button");
expect(button2.textContent).toContain("20");
expect(button2).not.toHaveProperty("disabled", true);
expect(localStorage.getItem(`likes-${post2}`)).toBeNull();
});
});
6. 起動が速い
Bun Test の強みは 起動が速い ところです。
たとえば、Jest は起動だけで 3〜5 秒かかることがある一方、Bun Test は 300ms 以内を狙えるケースが多い。
ただし、CI での総実行時間は「テストの数」「依存解決」「CPU」「I/O」などの影響も受けるので、ここは現実的に見ましょう(だからこそ、次の画像が説得力を持ちます)。
7. 実録!驚異の 2 秒フィードバック(CI でのテスト実行時間)
CI 環境でテストを実行した結果がこちらです。

以前の Node.js + Jest 環境では、テストが開始される前の「セットアップ待ち」だけで 5 秒以上かかり、全体の完了まで 20〜30 秒待つのが当たり前でした。しかし、Bun はテストランナーがランタイムに内蔵されているため、起動オーバーヘッドがほぼゼロ。この「待たされない」スピード感こそが、開発者の集中力を削がず、スムーズな開発体験(DX)を大幅に向上させます!
Step 3: CI 環境の構築(GitHub Actions での比較)
「ローカルが速いのはわかった。でも CI は?」―― 安心してください、CI も爆速です。
ここでは .github/workflows/ci.yml の全文も載せつつ、Node/npm/pnpm で組む場合と何が違うのかを「見れば分かる」形にします。
比較検証:Node vs Bun
これまでの Node.js 環境と、今回の Bun 環境を比較してみましょう。
| 項目 | Node.js (pnpm/npm/yarn) | Bun |
|---|---|---|
| セットアップ |
setup-node + setup-pnpm + cache 設定 |
setup-bun 一発のみ |
| インストール | キャッシュがあっても数秒〜十数秒 | ほぼ一瞬 |
| 複雑さ | yaml ファイルが長くなりがち | シンプル |
さらに「処理の流れ」自体も短くなります。イメージとしてはこんな感じです。
なぜ Bun のほうが工程が少ないのか?
.github/workflows/ci.yml
実際に動かしている CI ワークフローはこれです。
name: Quality Assurance with Bun
on:
push:
branches: [master]
pull_request:
branches: [master]
jobs:
quality-check:
name: Lint, Test and Build
runs-on: ubuntu-latest
steps:
# リポジトリコードをチェックアウト
- name: Checkout repository
uses: actions/checkout@v4
# Bun ランタイム環境をセットアップ
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.4
# ロックファイルを凍結して依存関係をインストール
- name: Install dependencies
run: bun install --frozen-lockfile
# ESLint を実行してコード品質をチェック
- name: Run lint
run: bun run lint
# テストを実行
- name: Run tests
run: bun run test
# ビルドして TypeScript エラーやパスの問題を検出
- name: Build check
run: bun run build
CI 全体の実行時間
.github/workflows/ci.yml で実行した全工程(Lint, Test, Build)の結果を見てください。

26 秒です! 以前の環境なら、インストールが終わったかな?くらいの時間で、すべての品質チェックが完了しています。
Step 4: 【最終形態】ランタイムの完全移行(Level 4)
いよいよクライマックス。Next.js 自体を Bun のランタイム上で動かします。
注入魂の --bun フラグ
package.json の scripts を最終形態にアップデートします。
"scripts": {
"dev": "bun --bun next dev",
"build": "bun --bun next build",
"start": "bun --bun next start",
"test": "bun test tests/",
"test:watch": "bun test tests/ --watch",
"lint": "eslint"
}
--bun フラグを付けることで、Next.js の背後にある Node.js プロセスが Bun に置き換わります。これにより、実行時のパフォーマンス向上が期待できます。
ビルド実録
CI 上での Next.js ビルド(Static Generation を含む)の結果がこちら。

10 秒です! 複雑なページ生成を含むビルドがこの速度で終わるのは、まさに異次元。Node 環境では考えられなかった数値です。
One More Thing: 継続的デプロイ(CD)の戦略
「ローカルや CI を Bun にしたのはいいけど、本番環境へのデプロイ(CD)はどうするの?」
私のプロジェクトは Vercel にデプロイしていますが、移行にあたって 2 つのプランを検討しました。
プラン A:ビルドのみ Bun(安定・互換性重視)
-
方法: 追加設定は一切不要。Vercel で
bun.lockを検知させ、ビルドステップで Bun を使わせる。実行環境はデフォルトの Node.js。 - メリット: リスクゼロ。 既存のライブラリとの互換性を 100% 維持しつつ、ビルド待ち時間だけを削減できる。
- デメリット: 実行時(JSC エンジン)のパフォーマンスやメモリ効率の恩恵は限定的。
プラン B:ビルドも実行も Bun(パフォーマンス・革新重視)
方法: Vercel 側に「Bun を使う」ことを明示し、アプリ側でも「どのランタイムで動いているか」を確認できるようにします。
1) vercel.json(Bun を使う宣言)
{
"$schema": "https://openapi.vercel.sh/vercel.json",
"bunVersion": "1.x"
}
プロジェクトのルート直下に配置します。
2) package.json の engines(期待する実行環境を明示)
"engines": {
"node": ">=20",
"bun": ">=1.0.0"
}
3) app/page.tsx(ランタイム確認ログを仕込む)(Optional)
このプロジェクトでは、Server Component 側で console.log して「Bun で動いているか」を目視確認できるようにしています。
export default function Home() {
// 本番環境でのランタイムを検証するためにログ出力(サーバーサイド)
console.log(
`[Runtime Check] Current runtime: ${
process.versions.bun
? "Bun " + process.versions.bun
: "Node.js " + process.version
}`
);
return (
// ... 省略 ...
<div />
);
}
そして、本番環境での出力結果は Vercel の「Deployment Build Logs」で確認します。

[Runtime Check] Current runtime: Bun 1.3.4
メリット
- 究極の統一感: 「開発・CI・本番」が同じ Bun を前提にできる
- 確認が簡単: 上の Runtime Check で「本当に Bun か?」をログで即確認できる
デメリット
- ベータ(Beta)要素: 現時点 Vercel 側の Bun ランタイムはベータ扱い(環境差分をゼロとは言い切れない)

- 依存の相性: ごく一部の「Node の深い内部 API 前提」のライブラリで挙動差が出る可能性
私の my-blog-app は、勇気を持って プラン B で運用しています!

まとめ:あなたのプロジェクトへの推奨方案
Bun への移行は、もはや「技術的な冒険」ではなく「実利的な投資」です。
- 新規プロジェクトや小規模プロジェクト:迷わず プラン B。Bun の真のパワーを全方位で享受しましょう。
- 既存の大規模プロジェクト:まずは プラン A で CI/CD を高速化し、開発リズムを整える。その後、テストで安全を確認しながらプラン B へ段階的に移行するのがスマートです。
my-blog-app は、Bun という「爆速のエンジン」を手に入れました。Node.js + pnpm + Jest の構成で 2 - 3 分ほど要していた CI ワークフロー全体が、今やわずか 26 秒で完走するようになったのです!
皆さんも、まずは bun install から、週末に試してみませんか?その瞬間に、あなたの開発時間は『待ち時間』から『創造する時間』に変わるはずです。
参考リンク
- Bun 公式サイト
- Bun × Vercel ガイド(公式)
- Using the Bun Runtime with Vercel Functions
- 前回の記事:Anthropic×Bunの今、Bunを「4つの顔」で完全理解(Runtime/PM/Test/Bundler): 爆速の仕組みを深掘り
- my-blog-app リポジトリ
Discussion