🐙

Next.js プロジェクトを Bun へ完全移行実録:もう Node.js に戻れない!

に公開

はじめに:ロードマップを現実に

前回の記事で紹介した「Bun の 4 つの顔」と「段階的導入ロードマップ」、覚えていますか?「速いのはわかったけど、既存の Next.js プロジェクトに導入したらどうなるの?」という疑問を持った方も多いはず。

前回の記事はこちら:

https://zenn.dev/virginia0314/articles/051330cf707679

そこで今回は、私の 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

ここまでが「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.png

豆知識: 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.tomlpreload を設定し、毎回手動で 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 環境でテストを実行した結果がこちらです。

Bun Test CI Result

以前の 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)の結果を見てください。

Bun CI Overall

26 秒です! 以前の環境なら、インストールが終わったかな?くらいの時間で、すべての品質チェックが完了しています。


Step 4: 【最終形態】ランタイムの完全移行(Level 4)

いよいよクライマックス。Next.js 自体を Bun のランタイム上で動かします。

注入魂の --bun フラグ

package.jsonscripts を最終形態にアップデートします。

"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 を含む)の結果がこちら。

Bun CI Build Result

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.jsonengines(期待する実行環境を明示)

"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」で確認します。

Bun Runtime Check

[Runtime Check] Current runtime: Bun 1.3.4

メリット

  • 究極の統一感: 「開発・CI・本番」が同じ Bun を前提にできる
  • 確認が簡単: 上の Runtime Check で「本当に Bun か?」をログで即確認できる

デメリット

  • ベータ(Beta)要素: 現時点 Vercel 側の Bun ランタイムはベータ扱い(環境差分をゼロとは言い切れない)

Bun Vercel Beta

  • 依存の相性: ごく一部の「Node の深い内部 API 前提」のライブラリで挙動差が出る可能性

私の my-blog-app は、勇気を持って プラン B で運用しています!

Vercel Deployed


まとめ:あなたのプロジェクトへの推奨方案

Bun への移行は、もはや「技術的な冒険」ではなく「実利的な投資」です。

  • 新規プロジェクトや小規模プロジェクト:迷わず プラン B。Bun の真のパワーを全方位で享受しましょう。
  • 既存の大規模プロジェクト:まずは プラン A で CI/CD を高速化し、開発リズムを整える。その後、テストで安全を確認しながらプラン B へ段階的に移行するのがスマートです。

my-blog-app は、Bun という「爆速のエンジン」を手に入れました。Node.js + pnpm + Jest の構成で 2 - 3 分ほど要していた CI ワークフロー全体が、今やわずか 26 秒で完走するようになったのです!

皆さんも、まずは bun install から、週末に試してみませんか?その瞬間に、あなたの開発時間は『待ち時間』から『創造する時間』に変わるはずです。


参考リンク

  • Bun 公式サイト

https://bun.sh/

  • Bun × Vercel ガイド(公式)

https://bun.sh/docs/guides/deployment/vercel

  • Using the Bun Runtime with Vercel Functions

https://vercel.com/docs/functions/runtimes/bun

  • 前回の記事:Anthropic×Bunの今、Bunを「4つの顔」で完全理解(Runtime/PM/Test/Bundler): 爆速の仕組みを深掘り

https://zenn.dev/virginia0314/articles/051330cf707679

  • my-blog-app リポジトリ

https://github.com/Virginia-Zhang/my-blog-app

Discussion